Files
OneCAD-Draft/src/app/control/interactive.rs
T
Sebastian ab3e0723fd feat(display): plugin entity-class labels in the panel, records, and automation
Resolve entities' XDATA class-identity keys through the live plugin manager
(new `src/plugin/entity_labels.rs`) and qualify display types as
"<Label> (<Native>)" wherever the bare native type is shown today.

- The single-entity Properties panel title (and the node graph, which shares
  the same computation) becomes e.g. "Linear Element (Line)". Host-owned
  classifications (xref, dynamic constraints, underlays, surfaces) keep the
  native parenthesized part, so nothing they tell the user is lost.
- The records API's `entities` collection: `display_type` carries the
  qualified label; the raw `type` / `record_type` fields stay native so
  existing scripts, type filters, and by-type grouping keep working.
- The automation entity summary gains a `display_type` field with the same
  semantics; `type` stays raw. Exposed by `query`, `user_select` sessions,
  and `get_selection`.

Semantics: the identity (a stable class KEY like "CVIFC-CLASS:LINE") lives
in the entity's XDATA and round-trips the drawing; the display LABELS live
in the declaring plugin's manifest. Keys only — no display text is ever
written to the file, and the label never governs behavior (display-only: it
does not affect snapping, selection, filtering, or grouping). Gating rides
live manager state at query time, so without a loaded declaring plugin every
surface here is byte-identical to today, immediately after any reopen, with
no re-assert pass. The command-line LIST output deliberately stays on the raw
dxf name. An app id claimed by more than one loaded plugin resolves through
the first-loaded declaration and debug-logs the ignored duplicate; a
prefix-only value, an unknown key, or an empty label mapping fall back to
the native type.

Tests: resolver core (known key, foreign app ignored, record without an
identity value ignored, prefix-only / unknown key / empty label fallback,
duplicate app id first-wins), the empty-manager gating end-to-end through
`with_manager`, the "<Label> (<Native>)" form, and the byte-identical
fallbacks asserted at the panel, records, and query layers for entities
stamped with a perfect identity record.
2026-10-15 22:39:09 +02:00

423 lines
16 KiB
Rust

//! Interactive user-request operations — selection and point requests
//! answered by the person at the screen. A client asks the
//! person at the screen to pick entities; the operation stays pending
//! (`running` for pollers) until that person answers with Enter or Escape,
//! then resolves with the picked entities' full data.
use super::*;
use crate::app::automation::{entity_json, entity_type_matches};
impl OpenCADStudio {
/// `user_select` — hand the screen to the person at the desk: they pick
/// entities (optionally filtered by type / layer), Enter confirms, Escape
/// cancels. The operation resolves only after that answer, so callers
/// must treat `running` as "still waiting for the user", not as a hang.
pub(super) fn control_user_select(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Err(failure("no_document", "Create or open a drawing first"));
}
// Enter and Escape are the answer keys; a live command or dialog owns
// them, so refuse rather than race the user's keystrokes.
if self.tabs[i].active_cmd.is_some() || self.active_modal.is_some() {
return Err(failure(
"user_input_busy",
"The user is already interacting with a command or dialog",
));
}
if self.main_window.is_none() {
return Err(failure(
"gui_required",
"user_select needs the desktop GUI where a person can answer",
));
}
if self.control.get_point.is_some() || self.control.user_select.is_some() {
return Err(failure(
"interactive_pending",
"Another interactive request is already waiting for the user",
));
}
let detail = req["detail"].as_str().unwrap_or("full");
if !matches!(detail, "summary" | "geometry" | "full") {
return Err(failure(
"invalid_detail",
"detail must be summary, geometry or full",
));
}
let type_filter = req["type"]
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
let layer_filter = req["layer"]
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
// Selection-set semantics: the request starts a fresh pick unless the
// caller asks to keep what is already selected.
if req["clear"].as_bool().unwrap_or(true) {
self.tabs[i].scene.deselect_all();
}
let prompt = req["prompt"]
.as_str()
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| {
crate::t!("Select objects, then press Enter to finish (Esc cancels).").into_owned()
});
// Label the request on screen: the message list must say WHO is
// waiting and for what, and the line stays pinned (non-fading) for
// as long as the request is open — see `pending_pick_label`.
let request_id = req["request_id"].as_str().unwrap_or_default().to_owned();
let label = crate::tf!(
"[user_select · {}] {} ({})",
if request_id.is_empty() {
"client"
} else {
&request_id
},
prompt,
crate::t!("Enter confirms, Esc cancels"),
)
.into_owned();
self.command_line.push_info(&label);
eprintln!(
"[pick] user_select {}: start -- waiting for the person (Enter confirms, Esc cancels)",
display_id(&request_id)
);
self.control.user_select = Some(UserSelectSession {
request_id,
document_id: self.tabs[i].id,
type_filter,
layer_filter,
detail: detail.to_owned(),
label,
});
Ok(Task::none())
}
/// Settle the pending request once the user answered. `confirmed` is the
/// Enter branch (selection read + filter validation); Escape resolves
/// immediately with `cancelled`. While the session lives the operation
/// stays off the settle path (its `pending` counter is held at one), so
/// pollers see `running` for as long as the person keeps working.
pub(in crate::app) fn resolve_user_select(&mut self, confirmed: bool) {
let Some(session) = self.control.user_select.take() else {
return;
};
if !confirmed {
self.command_line.push_info(crate::t!("*Cancel*").as_ref());
self.command_line.push_info(
crate::tf!(
"user_select {}: the request was answered with no objects.",
session.request_id
)
.as_ref(),
);
eprintln!(
"[pick] user_select {}: the person pressed Esc -- cancelled",
display_id(&session.request_id)
);
self.finish_interactive(
"user_select",
&session.request_id,
true,
json!({"cancelled": true, "count": 0, "handles": [], "entities": []}),
);
return;
}
let index = self
.tabs
.iter()
.position(|tab| tab.id == session.document_id);
let Some(i) = index else {
eprintln!(
"[pick] user_select {}: document closed -- cancelled",
display_id(&session.request_id)
);
self.finish_interactive(
"user_select",
&session.request_id,
true,
json!({"cancelled": true, "reason": "document_closed", "count": 0, "handles": [], "entities": []}),
);
return;
};
// The filter only gates the answer, not the user's gestures: anything
// that does not match is deselected (and reported) at confirm time.
let picked = self.tabs[i].scene.selected_handles_in_order();
let mut handles: Vec<codec::Handle> = Vec::new();
let mut ignored = 0usize;
for handle in picked {
let matches = self.tabs[i]
.scene
.document
.get_entity(handle)
.is_some_and(|entity| {
session
.type_filter
.as_deref()
.is_none_or(|filter| entity_type_matches(entity, filter))
&& session
.layer_filter
.as_deref()
.is_none_or(|filter| entity.common().layer == filter)
});
if matches {
handles.push(handle);
} else {
ignored += 1;
self.tabs[i].scene.deselect_entity(handle);
}
}
if ignored > 0 {
self.command_line.push_info(
crate::tf!(
"{} object(s) ignored: outside the requested type/layer filter.",
ignored
)
.as_ref(),
);
}
let stored: Vec<String> = handles
.iter()
.map(|handle| format!("{:X}", handle.value()))
.collect();
let entities: Vec<Value> = handles
.iter()
.filter_map(|handle| {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.map(|entity| {
entity_json(
entity,
&session.detail,
crate::plugin::entity_labels::custom_entity_class_label(
&self.disabled_plugins,
entity,
),
)
})
})
.collect();
let count = entities.len();
self.command_line.push_info(
crate::tf!(
"user_select {}: handed {} object(s) to the client.",
session.request_id,
count
)
.as_ref(),
);
eprintln!(
"[pick] user_select {}: the person confirmed -- {} object(s) handed over",
display_id(&session.request_id),
count
);
self.finish_interactive(
"user_select",
&session.request_id,
false,
json!({
"cancelled": false,
"count": count,
"ignored": ignored,
"handles": stored,
"entities": entities,
}),
);
}
/// `getpoint` — ask the person at the screen to pick one point. The next
/// left-click in the pinned document's viewport becomes the answer (the
/// same snapped world point a command would receive); Escape cancels.
/// The operation stays `running` until then, so callers may hold the
/// HTTP request open for as long as the person takes.
pub(super) fn control_getpoint(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Err(failure("no_document", "Create or open a drawing first"));
}
if self.tabs[i].active_cmd.is_some() || self.active_modal.is_some() {
return Err(failure(
"user_input_busy",
"The user is already interacting with a command or dialog",
));
}
if self.control.get_point.is_some() || self.control.user_select.is_some() {
return Err(failure(
"interactive_pending",
"Another interactive request is already waiting for the user",
));
}
if self.main_window.is_none() {
return Err(failure(
"gui_required",
"getpoint needs the desktop GUI where a person can answer",
));
}
let prompt = req["prompt"]
.as_str()
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| crate::t!("Pick a point, then click (Esc cancels).").into_owned());
let request_id = req["request_id"].as_str().unwrap_or_default().to_owned();
let label = crate::tf!(
"[getpoint · {}] {} ({})",
if request_id.is_empty() {
"client"
} else {
&request_id
},
prompt,
crate::t!("Click confirms, Esc cancels"),
)
.into_owned();
self.command_line.push_info(&label);
eprintln!(
"[pick] getpoint {}: start -- waiting for the person (Click confirms, Esc cancels)",
display_id(&request_id)
);
self.control.get_point = Some(UserPointSession {
request_id,
document_id: self.tabs[i].id,
label,
});
Ok(Task::none())
}
/// Settle a `getpoint` request: `Some(point)` is the picked answer,
/// `None` the Escape (or tab-close) cancellation.
pub(in crate::app) fn resolve_get_point(&mut self, point: Option<[f64; 3]>) {
let Some(session) = self.control.get_point.take() else {
return;
};
// Drop the snap marker the pending pick was showing — no command is
// running, so nothing else would clear it after the session ends.
if let Some(i) = self
.tabs
.iter()
.position(|tab| tab.id == session.document_id)
{
self.tabs[i].snap_result = None;
}
let (cancelled, result) = match point {
Some(point) => (false, json!({"point": point})),
None => (true, json!({"cancelled": true})),
};
if cancelled {
self.command_line.push_info(crate::t!("*Cancel*").as_ref());
}
self.command_line.push_info(
crate::tf!(
"getpoint {}: {}",
session.request_id,
match point {
Some(point) => format!("{point:?}"),
None => crate::t!("no point — the request was cancelled.").into_owned(),
}
)
.as_ref(),
);
match point {
Some(point) => eprintln!(
"[pick] getpoint {}: the person picked {:?}",
display_id(&session.request_id),
point
),
None => eprintln!(
"[pick] getpoint {}: the person pressed Esc -- cancelled",
display_id(&session.request_id)
),
}
self.finish_interactive("getpoint", &session.request_id, cancelled, result);
}
/// The pinned command-line label of a pending client pick (`user_select`
/// or `getpoint`). The end-of-update driver feeds this to
/// `CommandLine::set_step_prompt` so the request line stays on screen —
/// pinned, non-fading — until the person answers, then it un-pins.
pub(in crate::app) fn pending_pick_label(&self) -> Option<String> {
self.control
.user_select
.as_ref()
.map(|session| session.label.clone())
.or_else(|| self.control.get_point.as_ref().map(|s| s.label.clone()))
}
/// Stamp an interactive answer into the pending operation and let it
/// settle. `cancelled` flips the final status to "cancelled". The
/// request_id + op check keeps a stale answer from writing into someone
/// else's result slot.
fn finish_interactive(&mut self, op: &str, request_id: &str, cancelled: bool, result: Value) {
if let Some(pending) = self
.control
.pending
.as_mut()
.filter(|p| p.id == request_id && p.request["op"] == op)
{
pending.result = result;
pending.cancelled = cancelled;
pending.pending = 0;
}
self.control_settle();
}
/// `get_selection` — read-only snapshot of the active tab's selection
/// (the read-back half of the `user_select` sampling flow). `handle`,
/// `type`, `display_type`, `layer` and `bounds` come straight from the
/// `query` output so clients share one parser; `text`/`value` expose the
/// stripped and raw strings for TEXT/MTEXT, and `block`/`position`
/// identify an INSERT's block definition and insertion point (null
/// otherwise). Reads `scene.selection` and never mutates it.
pub(in crate::app) fn control_get_selection(&self) -> Value {
let scene = &self.tabs[self.active_tab].scene;
let entities: Vec<Value> = scene
.selected_handles_in_order()
.iter()
.filter_map(|handle| {
scene.document.get_entity(*handle).map(|entity| {
let full = entity_json(
entity,
"full",
crate::plugin::entity_labels::custom_entity_class_label(
&self.disabled_plugins,
entity,
),
);
json!({
"handle": full["handle"],
"type": full["type"],
"display_type": full["display_type"],
"layer": full["layer"],
"bounds": full["bounds"],
"text": full.get("text").cloned().unwrap_or(Value::Null),
"value": full.get("value").cloned().unwrap_or(Value::Null),
"block": full.get("block").cloned().unwrap_or(Value::Null),
"position": full.get("position").cloned().unwrap_or(Value::Null),
})
})
})
.collect();
json!({
"ok": true,
"status": "completed",
"result": {"count": entities.len(), "entities": entities},
})
}
}
/// Stable diagnostic name for a request that arrived without an id, shared
/// by the on-screen label and the stderr `[pick]` lifecycle lines so the
/// client's own log can be cross-referenced line for line.
fn display_id(request_id: &str) -> &str {
if request_id.is_empty() {
"client"
} else {
request_id
}
}