forked from 1b-agent/opencadstudio-upstream
feat(plugin): harden V4 host integration
Preserve legacy wire indices and route concurrent V4 responses and background requests by correlation and stable tab ID.
This commit is contained in:
commit
f247d15f6e
110 files changed
+32159
-1254
No files matched your search
+192
-31
@@ -1110,39 +1110,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
.get(idx)
|
||||
.map(|l| l.name.clone())
|
||||
.unwrap_or_default();
|
||||
if !new_name.is_empty()
|
||||
&& new_name != old_name
|
||||
&& !self.tabs[i].scene.document.layers.contains(&new_name)
|
||||
{
|
||||
if !new_name.is_empty() && new_name != old_name {
|
||||
self.push_undo_snapshot(i, "LAYER RENAME");
|
||||
// Keep the whole record (handle, color, linetype,
|
||||
// lineweight, flags) and only change the name, so the
|
||||
// renamed layer still has a valid handle and survives a
|
||||
// DWG save (issue #67).
|
||||
if let Some(mut nl) =
|
||||
self.tabs[i].scene.document.layers.get(&old_name).cloned()
|
||||
{
|
||||
nl.name = new_name.clone();
|
||||
if !nl.handle.is_valid() {
|
||||
nl.handle = self.tabs[i].scene.document.allocate_handle();
|
||||
}
|
||||
let _ = self.tabs[i].scene.document.layers.add(nl);
|
||||
if !self.tabs[i].rename_layer(&old_name, &new_name) {
|
||||
self.discard_last_undo_entry(i);
|
||||
}
|
||||
self.tabs[i].scene.document.layers.remove(&old_name);
|
||||
for e in self.tabs[i].scene.document.entities_mut() {
|
||||
if e.as_entity().layer() == old_name {
|
||||
e.as_entity_mut().set_layer(new_name.clone());
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||||
let vp_info = self.tabs[i].scene.viewport_list();
|
||||
self.tabs[i]
|
||||
.layers
|
||||
.sync_with_viewports(&doc_layers, vp_info);
|
||||
self.tabs[i].layers.edit_buf.clear();
|
||||
self.sync_ribbon_layers();
|
||||
self.refresh_layer_panel();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -1991,7 +1966,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
// selected inserts to the picked definition. A stale
|
||||
// typed value in the text buffer would mask the pick,
|
||||
// so drop it; the pick also closes the list.
|
||||
self.tabs[i].properties.edit_buf.remove("block");
|
||||
self.tabs[i]
|
||||
.properties
|
||||
.edit_buf
|
||||
.remove(&crate::ui::properties::FieldKey::Geom("block"));
|
||||
self.tabs[i].properties.edit_choice_open = false;
|
||||
let canon = self.tabs[i]
|
||||
.scene
|
||||
@@ -2127,9 +2105,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
|
||||
pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].properties.active_field = None;
|
||||
let handles = self.property_target_handles(i);
|
||||
if !handles.is_empty() {
|
||||
if let Some(raw_val) = self.tabs[i].properties.edit_buf.remove(field) {
|
||||
if let Some(raw_val) = self.tabs[i]
|
||||
.properties
|
||||
.edit_buf
|
||||
.remove(&crate::ui::properties::FieldKey::Geom(field))
|
||||
{
|
||||
// Block names are free-form text — a name like "10-5"
|
||||
// must not be arithmetic-evaluated.
|
||||
let val = if field == "block" {
|
||||
@@ -2355,6 +2338,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
/// it is not run through the expression evaluator like numeric fields).
|
||||
pub(super) fn on_prop_attr_commit(&mut self, tag: String) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].properties.active_field = None;
|
||||
let key = crate::ui::properties::attr_edit_key(&tag);
|
||||
let handles = self.property_target_handles(i);
|
||||
if handles.is_empty() {
|
||||
@@ -2609,3 +2593,180 @@ fn block_attr_prompts(
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layer_rename_tests {
|
||||
use crate::app::OpenCADStudio;
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::{EntityType, Handle};
|
||||
|
||||
fn app_with_editing_layer() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = app.on_layer_new();
|
||||
app
|
||||
}
|
||||
|
||||
fn rename_layer(app: &mut OpenCADStudio, old_name: &str, new_name: &str) {
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.position(|layer| layer.name == old_name)
|
||||
.expect("layer exists in panel");
|
||||
app.tabs[i].layers.editing = Some(idx);
|
||||
app.tabs[i].layers.edit_buf = new_name.to_string();
|
||||
let _ = app.on_layer_rename_commit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_allows_ascii_case_only_change() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||||
rename_layer(&mut app, &old_name, "TEST");
|
||||
let handle = app.tabs[i].scene.document.layers.get("TEST").unwrap().handle;
|
||||
|
||||
rename_layer(&mut app, "TEST", "test");
|
||||
|
||||
let layer = app.tabs[i].scene.document.layers.get("test").unwrap();
|
||||
assert_eq!(layer.name, "test");
|
||||
assert_eq!(layer.handle, handle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_allows_unicode_case_only_change() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||||
rename_layer(&mut app, &old_name, "é");
|
||||
|
||||
rename_layer(&mut app, "é", "É");
|
||||
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.layers.get("É").unwrap().name,
|
||||
"É"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_layer_rename_updates_name_and_allocated_handle() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i].layers.editing.expect("new layer is being edited");
|
||||
let old_name = app.tabs[i].layers.layers[idx].name.clone();
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get_mut(&old_name)
|
||||
.unwrap()
|
||||
.handle = Handle::NULL;
|
||||
app.tabs[i].layers.selected = Some(idx);
|
||||
let _ = app.on_layer_set_current();
|
||||
|
||||
rename_layer(&mut app, &old_name, "Renamed");
|
||||
|
||||
let layer = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get("Renamed")
|
||||
.unwrap();
|
||||
assert!(layer.handle.is_valid());
|
||||
assert_eq!(app.tabs[i].active_layer, "Renamed");
|
||||
assert_eq!(app.tabs[i].layers.current_layer, "Renamed");
|
||||
assert_eq!(app.ribbon.active_layer, "Renamed");
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.header.current_layer_name,
|
||||
"Renamed"
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.header.current_layer_handle,
|
||||
layer.handle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_undo_redo_restores_active_layer() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i].layers.editing.expect("new layer is being edited");
|
||||
let old_name = app.tabs[i].layers.layers[idx].name.clone();
|
||||
app.tabs[i].layers.selected = Some(idx);
|
||||
let _ = app.on_layer_set_current();
|
||||
|
||||
rename_layer(&mut app, &old_name, "Renamed");
|
||||
app.undo_active_tab();
|
||||
|
||||
assert!(app.tabs[i].scene.document.layers.contains(&old_name));
|
||||
assert_eq!(app.tabs[i].active_layer, old_name);
|
||||
assert_eq!(app.ribbon.active_layer, app.tabs[i].active_layer);
|
||||
|
||||
app.redo_active_tab();
|
||||
|
||||
assert!(app.tabs[i].scene.document.layers.contains("Renamed"));
|
||||
assert_eq!(app.tabs[i].active_layer, "Renamed");
|
||||
assert_eq!(app.ribbon.active_layer, "Renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_keeps_independent_current_layer_mirrors() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||||
rename_layer(&mut app, &old_name, "A");
|
||||
let _ = app.on_layer_new();
|
||||
let b_idx = app.tabs[i].layers.editing.expect("new layer is being edited");
|
||||
app.tabs[i].layers.selected = Some(b_idx);
|
||||
let _ = app.on_layer_set_current();
|
||||
let header_name = app.tabs[i].scene.document.header.current_layer_name.clone();
|
||||
let header_handle = app.tabs[i].scene.document.header.current_layer_handle;
|
||||
app.tabs[i].active_layer = "A".to_string();
|
||||
|
||||
rename_layer(&mut app, "A", "Renamed");
|
||||
|
||||
assert_eq!(app.tabs[i].active_layer, "Renamed");
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.header.current_layer_name,
|
||||
header_name
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.header.current_layer_handle,
|
||||
header_handle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_updates_mixed_case_entity_references() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||||
rename_layer(&mut app, &old_name, "TEST");
|
||||
let mut line = Line::new();
|
||||
line.common.layer = "test".to_string();
|
||||
let handle = app.tabs[i].scene.add_entity(EntityType::Line(line));
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.invalidate_layer_dependencies(&["TEST".to_string()]);
|
||||
|
||||
rename_layer(&mut app, "TEST", "Renamed");
|
||||
|
||||
assert_eq!(
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.unwrap()
|
||||
.common()
|
||||
.layer,
|
||||
"Renamed"
|
||||
);
|
||||
let epoch = app.tabs[i].scene.geometry_epoch;
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.invalidate_layer_dependencies(&["Renamed".to_string()]);
|
||||
assert_ne!(app.tabs[i].scene.geometry_epoch, epoch);
|
||||
}
|
||||
}
|
||||
+156
-2
@@ -636,6 +636,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
}
|
||||
DockMsg::DragMove(point) => {
|
||||
if self.dock_dragging.is_some() {
|
||||
let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1;
|
||||
let side = if point.x < self.win_size.0 * 0.5 {
|
||||
DockSide::Left
|
||||
} else {
|
||||
@@ -643,8 +644,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
};
|
||||
let index = crate::ui::dock::drop_index(
|
||||
point.y,
|
||||
std::cmp::max(self.dock.len(side), 1),
|
||||
self.win_size.1,
|
||||
std::cmp::max(self.dock_visible_len(side), 1),
|
||||
avail,
|
||||
);
|
||||
self.dock_drag_target = Some((side, index));
|
||||
} else if let Some(id) = self.dock_resizing {
|
||||
@@ -680,6 +681,33 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` is currently rendered (not closed, not on the start screen /
|
||||
/// clean-screen viewport). Mirrors the visibility filter the edge column
|
||||
/// renderer applies before building each side's stack.
|
||||
pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
|
||||
use crate::ui::dock::PanelId;
|
||||
if self.tabs[self.active_tab].is_start || self.clean_screen {
|
||||
return false;
|
||||
}
|
||||
match id {
|
||||
PanelId::Properties => self.show_properties,
|
||||
PanelId::BlockPalette => self.show_block_palette,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of panels currently rendered on `side`. Closed panels keep their
|
||||
/// stack slot but take no vertical space, so drag preview geometry must
|
||||
/// size slots against only the panels that are actually visible.
|
||||
pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize {
|
||||
let ids: &[crate::ui::dock::PanelId] = match side {
|
||||
crate::app::config::DockSide::Left => &self.dock.left,
|
||||
crate::app::config::DockSide::Right => &self.dock.right,
|
||||
};
|
||||
ids.iter()
|
||||
.filter(|id| self.dock_panel_visible(**id))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Start placing `name` through the INSERT command, skipping the name prompt.
|
||||
fn start_block_placement(&mut self, name: &str) {
|
||||
let i = self.active_tab;
|
||||
@@ -1039,6 +1067,49 @@ mod tests {
|
||||
assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_reflects_new_block_without_reopen() {
|
||||
use acadrust::types::Transform;
|
||||
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
|
||||
// Reproduce the user-facing flow: select entities and run the BLOCK
|
||||
// command, which goes through `create_block_from_entities` (not the
|
||||
// clipboard / paste-as-block path).
|
||||
let mut line = Line::new();
|
||||
line.start = Vector3::ZERO;
|
||||
line.end = Vector3::new(10.0, 0.0, 0.0);
|
||||
let first = app.tabs[i].scene.add_entity(EntityType::Line(line));
|
||||
app.tabs[i].scene.select_entity(first, false);
|
||||
app.show_block_palette = true;
|
||||
let ws = Transform::identity();
|
||||
let id = Transform::identity();
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.create_block_from_entities(&[first], "First", &ws, &id)
|
||||
.unwrap();
|
||||
app.refresh_block_palette_if_stale();
|
||||
assert!(app.block_palette.blocks.iter().any(|b| b.name == "First"));
|
||||
|
||||
// Create a second block on the same tab, then re-run the per-update
|
||||
// stale check. It must pick up the new block WITHOUT reopening.
|
||||
line = Line::new();
|
||||
line.start = Vector3::ZERO;
|
||||
line.end = Vector3::new(9.0, 0.0, 0.0);
|
||||
let second = app.tabs[i].scene.add_entity(EntityType::Line(line));
|
||||
app.tabs[i].scene.select_entity(second, false);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.create_block_from_entities(&[second], "Second", &ws, &id)
|
||||
.unwrap();
|
||||
app.refresh_block_palette_if_stale();
|
||||
assert!(
|
||||
app.block_palette.blocks.iter().any(|b| b.name == "Second"),
|
||||
"Second must appear without reopening the panel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
|
||||
let mut app = fresh();
|
||||
@@ -1077,6 +1148,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_visible_len_counts_only_rendered_panels() {
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
// Left: block palette hidden, properties shown -> 1 visible panel.
|
||||
app.show_block_palette = false;
|
||||
app.show_properties = true;
|
||||
assert_eq!(
|
||||
app.dock_visible_len(crate::app::config::DockSide::Left),
|
||||
1
|
||||
);
|
||||
// Reveal the block palette -> both count.
|
||||
app.show_block_palette = true;
|
||||
assert_eq!(
|
||||
app.dock_visible_len(crate::app::config::DockSide::Left),
|
||||
2
|
||||
);
|
||||
// A hidden (closed) panel counts for nothing even when stacked.
|
||||
app.show_block_palette = false;
|
||||
assert_eq!(
|
||||
app.dock_visible_len(crate::app::config::DockSide::Left),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drag_target_ignores_hidden_panels_on_the_side() {
|
||||
// Reproduce the persisted layout that exposed a half-height ghost: two
|
||||
// panels live in the left stack but the block palette is hidden
|
||||
// (show_block_palette=false), so only Properties renders. Drag geometry
|
||||
// must count only panels that are actually visible.
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
app.show_block_palette = false;
|
||||
app.show_properties = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let id = crate::ui::dock::PanelId::Properties;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
// Pointer near the bottom of the left edge: one visible panel means a
|
||||
// single slot, so every y maps to index 0 (no top/bottom split).
|
||||
let _ =
|
||||
app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drag_target_counts_all_visible_panels() {
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
// Both panels shown: two real slots on the left edge. A pointer near
|
||||
// the bottom maps to the append slot (index == 2).
|
||||
app.show_block_palette = true;
|
||||
app.show_properties = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let id = crate::ui::dock::PanelId::Properties;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
let _ =
|
||||
app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_width_reset() {
|
||||
let mut app = fresh();
|
||||
|
||||
+11
-4
@@ -1154,13 +1154,20 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
crate::scene::text::ttf_glyph::clear_fallback_cache();
|
||||
}
|
||||
// Current model-space annotation scale comes from the drawing's
|
||||
// CANNOSCALEVALUE (paper/drawing factor); the multiplier we use
|
||||
// for text/dim sizing is its inverse (1:50 -> 0.02 -> 50.0).
|
||||
// CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into
|
||||
// drawing units as well: metric annotation sizes are paper millimetres
|
||||
// and imperial annotation sizes are paper inches.
|
||||
// Current model-space annotation scale comes from the drawing's
|
||||
// CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into
|
||||
// the drawing's INSUNITS as well, so e.g. a metre drawing uses
|
||||
// 0.001 model units for 1 mm of paper at 1:1.
|
||||
let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value;
|
||||
let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor();
|
||||
|
||||
self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 {
|
||||
(1.0 / cannoscale_value) as f32
|
||||
((1.0 / cannoscale_value) / unit_factor) as f32
|
||||
} else {
|
||||
1.0
|
||||
(1.0 / unit_factor) as f32
|
||||
};
|
||||
|
||||
// Open-time breakdown so regressions are visible immediately.
|
||||
|
||||
+122
-29
@@ -217,6 +217,20 @@ impl OpenCADStudio {
|
||||
self.reset_modal_geometry();
|
||||
}
|
||||
|
||||
/// Fire the focus-sweep once when a property field is active, so a
|
||||
/// click-away that moved focus off the field (viewport, toolbar, command
|
||||
/// line) clears the active-row highlight. Idle (`Task::none()`) unless a
|
||||
/// field is active, and the sweep itself keeps the marker whenever the
|
||||
/// active field still holds focus — re-firing it on unrelated messages is
|
||||
/// cheap and safe.
|
||||
fn sync_active_field_if_any(&self) -> Task<Message> {
|
||||
if self.tabs[self.active_tab].properties.active_field.is_some() {
|
||||
crate::ui::properties::sync_active_field_task()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||||
let perf_started = crate::perf::enabled().then(Instant::now);
|
||||
let perf_label = perf_message_label(&msg);
|
||||
@@ -308,34 +322,31 @@ impl OpenCADStudio {
|
||||
// user-generated message. Desktop only.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Message::DrainPluginRequests => {
|
||||
if self.active_tab < self.tabs.len() {
|
||||
let mut host = crate::app::plugin_host::HostSession::new(self, self.active_tab);
|
||||
for tab in 0..self.tabs.len() {
|
||||
let mut host = crate::app::plugin_host::HostSession::new(self, tab);
|
||||
crate::plugin::external::with_manager(|mgr| {
|
||||
mgr.drain_requests(&mut host, &mut |_| {});
|
||||
});
|
||||
crate::plugin::external::with_manager(|mgr| {
|
||||
for line in mgr.drain_io() {
|
||||
// Skip internal runner/plugin/REPL trace noise when
|
||||
// verbose logging is enabled; real plugin stdout/stderr
|
||||
// and non-trace runner errors still pass through.
|
||||
if line.text.starts_with("[runner]")
|
||||
|| line.text.starts_with("[plugin] ")
|
||||
|| line.text.starts_with("[python-repl]")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
crate::plugin::external::with_manager(|mgr| {
|
||||
for line in mgr.drain_io() {
|
||||
if line.text.starts_with("[runner]")
|
||||
|| line.text.starts_with("[plugin] ")
|
||||
|| line.text.starts_with("[python-repl]")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let prefixed = format!("[{} {}] {}", line.plugin_id, line.source, line.text);
|
||||
match line.source {
|
||||
ocs_plugin_api::process::IoStream::Stderr => {
|
||||
self.command_line.push_error(&prefixed);
|
||||
}
|
||||
let prefixed = format!("[{} {}] {}", line.plugin_id, line.source, line.text);
|
||||
match line.source {
|
||||
ocs_plugin_api::process::IoStream::Stderr => {
|
||||
self.command_line.push_error(&prefixed);
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_output(&prefixed);
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_output(&prefixed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -1195,7 +1206,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::RibbonToolClick { tool_id, event } => {
|
||||
self.on_ribbon_tool_click(tool_id, event)
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
Task::batch(vec![sweep, self.on_ribbon_tool_click(tool_id, event)])
|
||||
}
|
||||
Message::PluginFileDialogResult { command, path } => {
|
||||
if let Some(path) = path {
|
||||
@@ -1438,14 +1450,15 @@ impl OpenCADStudio {
|
||||
// the submit path, which tokenises multi-token lines — so a typed
|
||||
// token, a pasted `LINE 0,0 10,10`, or API-fed text all run their
|
||||
// spaces as step separators. A leading `>` keeps spaces literal.
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
if !self.command_line.literal_spaces && !s.starts_with('>') && s.contains(' ') {
|
||||
self.command_line.input = s;
|
||||
return self.update(Message::CommandSubmit);
|
||||
return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]);
|
||||
}
|
||||
self.command_line.input = s;
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.cancel_history_navigation();
|
||||
Task::none()
|
||||
Task::batch(vec![sweep, Task::none()])
|
||||
}
|
||||
|
||||
Message::CommandAppendChar(s) => self.on_command_append_char(s),
|
||||
@@ -2628,10 +2641,13 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
Message::PanePress(idx) => {
|
||||
// A click-away into the drawing area re-syncs the active-row
|
||||
// highlight against real focus before the pick runs.
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
// A fresh press ends any stale (un-dropped) pane move.
|
||||
self.pane_move_from = None;
|
||||
self.focus_model_pane(idx);
|
||||
self.on_viewport_left_press()
|
||||
Task::batch(vec![sweep, self.on_viewport_left_press()])
|
||||
}
|
||||
Message::PaneRelease(idx) => {
|
||||
// Finishing a pane-move drag: swap the source pane with the one
|
||||
@@ -2666,7 +2682,10 @@ impl OpenCADStudio {
|
||||
self.update(Message::ViewportScroll(d))
|
||||
}
|
||||
|
||||
Message::ViewportLeftPress => self.on_viewport_left_press(),
|
||||
Message::ViewportLeftPress => {
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
Task::batch(vec![sweep, self.on_viewport_left_press()])
|
||||
}
|
||||
|
||||
Message::ViewportLeftRelease => self.on_viewport_left_release(),
|
||||
|
||||
@@ -4613,7 +4632,7 @@ impl OpenCADStudio {
|
||||
self.tabs[self.active_tab]
|
||||
.properties
|
||||
.edit_buf
|
||||
.insert(field.to_string(), value);
|
||||
.insert(crate::ui::properties::FieldKey::Geom(field), value);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -4647,6 +4666,38 @@ impl OpenCADStudio {
|
||||
|
||||
Message::PropAttrCommit(tag) => self.on_prop_attr_commit(tag),
|
||||
|
||||
Message::PropPointerPressed => {
|
||||
if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) {
|
||||
return Task::none();
|
||||
}
|
||||
crate::ui::properties::sync_active_field_task()
|
||||
}
|
||||
|
||||
Message::PropSyncActive(focused) => {
|
||||
let panel = &mut self.tabs[self.active_tab].properties;
|
||||
if let Some(id) = focused.as_ref() {
|
||||
if let Some(key) = panel.prop_field_key_for_id(id) {
|
||||
let changed = panel.active_field.as_ref() != Some(&key);
|
||||
panel.active_field = Some(key);
|
||||
// Only select the whole value when focus landed on a
|
||||
// field that wasn't already the active one; re-focusing
|
||||
// the same field (or sweeping after a click elsewhere
|
||||
// left its focus in place) must keep caret placement.
|
||||
if changed {
|
||||
return iced::widget::operation::select_all(id.clone());
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if !crate::ui::properties::active_key_focused(
|
||||
panel.active_field.as_ref(),
|
||||
focused.as_ref(),
|
||||
) {
|
||||
panel.active_field = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::PropColorPickerToggle => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].properties.color_picker_open =
|
||||
@@ -5992,7 +6043,11 @@ impl OpenCADStudio {
|
||||
{
|
||||
// Stop the plugin runner first so Windows releases the DLL
|
||||
// and allows the package directory to be deleted.
|
||||
crate::plugin::external::remove_plugin(&id);
|
||||
if !crate::plugin::external::remove_plugin(&id) {
|
||||
self.marketplace_status =
|
||||
format!("Uninstall failed: plugin '{id}' did not stop in time");
|
||||
return Task::none();
|
||||
}
|
||||
match crate::plugin::external::uninstall(&id) {
|
||||
Ok(()) => {
|
||||
self.marketplace_status =
|
||||
@@ -7516,3 +7571,41 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prop_pointer_tests {
|
||||
use super::Message;
|
||||
use crate::app::OpenCADStudio;
|
||||
use crate::ui::dock::PanelId;
|
||||
|
||||
fn drawing_app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_properties_panel_skips_the_focus_sweep() {
|
||||
// A click while the panel is closed must not run the widget-tree sweep:
|
||||
// the returned task is a bare `Task::none` (units == 0).
|
||||
let mut app = drawing_app();
|
||||
assert!(app.dock_panel_visible(PanelId::Properties));
|
||||
app.show_properties = false;
|
||||
assert!(!app.dock_panel_visible(PanelId::Properties));
|
||||
|
||||
let task = app.update(Message::PropPointerPressed);
|
||||
assert_eq!(task.units(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_properties_panel_runs_the_focus_sweep() {
|
||||
// A click while the panel is open must still fire the sweep (units > 0)
|
||||
// so the select-whole-value feature keeps working.
|
||||
let mut app = drawing_app();
|
||||
app.show_properties = true;
|
||||
assert!(app.dock_panel_visible(PanelId::Properties));
|
||||
|
||||
let task = app.update(Message::PropPointerPressed);
|
||||
assert!(task.units() > 0);
|
||||
}
|
||||
}
|
||||
+66
-37
@@ -4206,46 +4206,75 @@ impl OpenCADStudio {
|
||||
height: vh,
|
||||
};
|
||||
|
||||
// 1) Try direct wire hit — works when the border is clicked.
|
||||
let wire_hit: Option<acadrust::Handle> = {
|
||||
let (view_rot, eye) = {
|
||||
let c = self.tabs[i].scene.camera.borrow();
|
||||
(c.view_proj_rte(bounds), c.eye())
|
||||
};
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
|
||||
let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
|
||||
all_wires,
|
||||
click_world,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
|
||||
);
|
||||
scene::pick::hit_test::click_hit(
|
||||
p,
|
||||
&click_candidates,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
self.tabs[i].scene.document.header.lineweight_display,
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
|
||||
)
|
||||
.and_then(|s| Scene::handle_from_wire_name(s))
|
||||
.and_then(|h| {
|
||||
if let Some(AcadEntityType::Viewport(vp)) =
|
||||
self.tabs[i].scene.document.get_entity(h)
|
||||
{
|
||||
if Scene::is_content_viewport(vp) {
|
||||
Some(h)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let (view_rot, eye) = {
|
||||
let c = self.tabs[i].scene.camera.borrow();
|
||||
(c.view_proj_rte(bounds), c.eye())
|
||||
};
|
||||
|
||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
|
||||
let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
|
||||
all_wires,
|
||||
click_world,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
|
||||
);
|
||||
|
||||
let raw_wire_hit = scene::pick::hit_test::click_hit(
|
||||
p,
|
||||
&click_candidates,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
self.tabs[i].scene.document.header.lineweight_display,
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
|
||||
)
|
||||
.and_then(|s| Scene::handle_from_wire_name(s));
|
||||
|
||||
// In paper space, text editing takes precedence over entering MSPACE.
|
||||
// This mirrors the model-space double-click behaviour.
|
||||
if let Some(handle) = raw_wire_hit {
|
||||
let is_editable_text = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.is_some_and(|entity| {
|
||||
crate::app::text_inline::read_text_field(entity).is_some()
|
||||
|| matches!(entity, AcadEntityType::Leader(_))
|
||||
});
|
||||
|
||||
if is_editable_text {
|
||||
if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) {
|
||||
self.command_line.push_info(
|
||||
crate::tf!(
|
||||
"Object is on locked layer \"{layer}\" — unlock the layer to edit it."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
return self.begin_text_edit(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// If the double-click was not on editable text, keep the existing
|
||||
// paper-space behaviour and try to enter the clicked viewport.
|
||||
let wire_hit: Option<acadrust::Handle> = raw_wire_hit.and_then(|h| {
|
||||
if let Some(AcadEntityType::Viewport(vp)) =
|
||||
self.tabs[i].scene.document.get_entity(h)
|
||||
{
|
||||
if Scene::is_content_viewport(vp) {
|
||||
Some(h)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// 2) Decide which viewport (if any) the click enters,
|
||||
// keyed on the *visible* on-screen rectangle. Screen-space
|
||||
|
||||
Reference in new issue
Block a user