Fix Properties hyperlink collection editor

This commit is contained in:
ramox81 committed 2026-09-17 18:17:25 +03:00
1 parent bb29b987c2
commit 6a1c649016
11 files changed
+272 -13

No files matched your search

+10 -6
View File
@@ -1163,7 +1163,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("HYPERLINK ") => {
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
use acadrust::xdata::XDataValue;
let url = cmd.strip_prefix("HYPERLINK").unwrap_or("").trim().to_string();
if url.is_empty() {
self.command_line.push_info(crate::t!("Usage: HYPERLINK <url> (select objects first)").as_ref());
@@ -1183,15 +1183,19 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "HYPERLINK");
let mut n = 0usize;
for h in &handles {
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
let xd = &mut e.common_mut().extended_data;
let mut rec = ExtendedDataRecord::new("PE_URL");
rec.add_value(XDataValue::String(url.clone()));
xd.add_record(rec);
if self.tabs[i].scene.document.get_entity(*h).is_some() {
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
*h,
"PE_URL",
Some(vec![XDataValue::String(url.clone())]),
);
n += 1;
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line
.push_output(crate::tf!("HYPERLINK: attached to {n} object(s).").as_ref());
return Some(Task::none());
+5 -1
View File
@@ -122,6 +122,7 @@ impl OpenCADStudio {
PropValue::ReadOnly(v)|PropValue::ReadOnlyWithTooltip{value:v,..}=>("readonly",json!(v),Value::Null),
PropValue::EditText(v)=>("number",json!(v),Value::Null),
PropValue::PlainText(v)=>("text",json!(v),Value::Null),
PropValue::Hyperlink(v)=>("hyperlink",json!(v),Value::Null),
PropValue::Choice{selected,options}=>("choice",json!(selected),json!(options)),
PropValue::EditChoice{value,options}=>("editable_choice",json!(value),json!(options)),
PropValue::LayerChoice(v)=>("layer",json!(v),Value::Null),
@@ -157,7 +158,10 @@ impl OpenCADStudio {
.map(str::to_owned)
.unwrap_or_else(|| req["value"].to_string());
Ok(match p.value {
PropValue::EditText(_) | PropValue::PlainText(_) | PropValue::EditChoice { .. } => {
PropValue::EditText(_)
| PropValue::PlainText(_)
| PropValue::Hyperlink(_)
| PropValue::EditChoice { .. } => {
let input = self.update(Message::PropGeomInput {
field: p.field,
value,
+20
View File
@@ -772,6 +772,12 @@ pub(super) struct OpenCADStudio {
/// The open in-canvas modal dialog, if any (Plan B: shared overlay instead
/// of OS windows).
active_modal: Option<ModalKind>,
/// Selection and staged values owned by the Properties hyperlink dialog.
hyperlink_editor_handles: Vec<acadrust::Handle>,
hyperlink_editor_url: String,
hyperlink_editor_description: String,
hyperlink_editor_mixed: bool,
hyperlink_editor_dirty: bool,
pending_startup_modals: std::collections::VecDeque<ModalKind>,
/// What is drawing the scene, once the first frame has told us. Drives
/// the graphics warning (popup, status-bar pill, command line).
@@ -1823,6 +1829,8 @@ pub enum ModalKind {
/// Add / remove the annotation scales a single selected object has a
/// per-object representation for.
AnnoObjectScale,
/// URL/description collection editor opened by the Hyperlink property row.
Hyperlink,
InsertTable,
DataLinkManager,
DataExtraction,
@@ -2648,6 +2656,13 @@ pub enum Message {
ScaleManagerOpen,
/// Open the Annotation Object Scale dialog for the current single selection.
AnnoObjectScaleOpen,
/// Open and edit the selected objects' PE_URL hyperlink collection.
PropHyperlinkOpen,
HyperlinkUrlChanged(String),
HyperlinkDescriptionChanged(String),
HyperlinkApply,
HyperlinkRemove,
HyperlinkCancel,
/// Toggle whether the dialog's object has a representation for this scale.
AnnoObjectScaleToggle(String),
/// Select a scale row in the manager (loads it into the editor).
@@ -3904,6 +3919,11 @@ impl OpenCADStudio {
color_picker_tab: ColorPickerTab::Index,
recent_colors: Vec::new(),
active_modal: None,
hyperlink_editor_handles: Vec::new(),
hyperlink_editor_url: String::new(),
hyperlink_editor_description: String::new(),
hyperlink_editor_mixed: false,
hyperlink_editor_dirty: false,
pending_startup_modals: std::collections::VecDeque::new(),
gpu_status: crate::scene::pipeline::GpuStatus::Unknown,
gpu_status_generation: 0,
+6 -1
View File
@@ -3234,6 +3234,7 @@ fn make_sections_read_only(sections: &mut [crate::scene::model::object::PropSect
| PropValue::ReadOnlyWithTooltip { value, .. }
| PropValue::EditText(value)
| PropValue::PlainText(value)
| PropValue::Hyperlink(value)
| PropValue::LayerChoice(value)
| PropValue::LinetypeChoice(value)
| PropValue::HatchPatternChoice(value) => value.clone(),
@@ -3508,6 +3509,9 @@ fn merge_prop_value(
(PropValue::PlainText(_), PropValue::PlainText(_)) => {
PropValue::PlainText(VARIES_LABEL.into())
}
(PropValue::Hyperlink(_), PropValue::Hyperlink(_)) => {
PropValue::Hyperlink(VARIES_LABEL.into())
}
(PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => {
PropValue::ReadOnly(VARIES_LABEL.into())
}
@@ -3581,7 +3585,8 @@ fn update_row_text(
match &mut row.value {
PropValue::ReadOnly(current)
| PropValue::EditText(current)
| PropValue::PlainText(current) => *current = value,
| PropValue::PlainText(current)
| PropValue::Hyperlink(current) => *current = value,
PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = value,
PropValue::Choice { selected, .. } => *selected = value,
_ => {}
+107
View File
@@ -200,6 +200,13 @@ impl OpenCADStudio {
self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute;
}
Some(GeometricTolerance) => self.geometric_tolerance = None,
Some(Hyperlink) => {
self.hyperlink_editor_handles.clear();
self.hyperlink_editor_url.clear();
self.hyperlink_editor_description.clear();
self.hyperlink_editor_mixed = false;
self.hyperlink_editor_dirty = false;
}
// Closing (✕) discards edits made since the last Apply — matching the
// style editors. Committing happens only through the Apply button.
Some(Aliases) => {
@@ -4085,6 +4092,106 @@ impl OpenCADStudio {
}
Task::none()
}
Message::PropHyperlinkOpen => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if handles.is_empty() {
return Task::none();
}
let mut first: Option<(String, String)> = None;
let mut mixed = false;
for handle in &handles {
let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) else {
continue;
};
let current = (
crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(),
crate::scene::pe_url_description_of(entity)
.unwrap_or_default()
.to_owned(),
);
if first.as_ref().is_some_and(|value| value != &current) {
mixed = true;
} else if first.is_none() {
first = Some(current);
}
}
let (url, description) = if mixed {
(String::new(), String::new())
} else {
first.unwrap_or_default()
};
self.hyperlink_editor_handles = handles;
self.hyperlink_editor_url = url;
self.hyperlink_editor_description = description;
self.hyperlink_editor_mixed = mixed;
self.hyperlink_editor_dirty = false;
self.active_modal = Some(crate::app::ModalKind::Hyperlink);
Task::none()
}
Message::HyperlinkUrlChanged(value) => {
self.hyperlink_editor_url = value;
self.hyperlink_editor_dirty = true;
Task::none()
}
Message::HyperlinkDescriptionChanged(value) => {
self.hyperlink_editor_description = value;
self.hyperlink_editor_dirty = true;
Task::none()
}
Message::HyperlinkApply => {
if !self.hyperlink_editor_dirty {
self.close_active_modal();
return Task::none();
}
let url = self.hyperlink_editor_url.trim().to_owned();
if self.hyperlink_editor_mixed && url.is_empty() {
self.command_line.push_info(
crate::t!("Enter a URL, or use Remove to clear all hyperlinks.").as_ref(),
);
return Task::none();
}
let description = self.hyperlink_editor_description.trim().to_owned();
let values = if url.is_empty() {
None
} else {
let mut values = vec![acadrust::xdata::XDataValue::String(url)];
if !description.is_empty() {
values.push(acadrust::xdata::XDataValue::String(description));
}
Some(values)
};
let i = self.active_tab;
let handles = self.hyperlink_editor_handles.clone();
self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| {
crate::scene::view::dispatch::set_entity_xdata(
&mut app.tabs[i].scene.document,
handle,
"PE_URL",
values.clone(),
);
});
self.close_active_modal();
Task::none()
}
Message::HyperlinkRemove => {
let i = self.active_tab;
let handles = self.hyperlink_editor_handles.clone();
self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| {
crate::scene::view::dispatch::set_entity_xdata(
&mut app.tabs[i].scene.document,
handle,
"PE_URL",
None,
);
});
self.close_active_modal();
Task::none()
}
Message::HyperlinkCancel => {
self.close_active_modal();
Task::none()
}
Message::AnnoObjectScaleToggle(name) => {
let i = self.active_tab;
if let Some(entity) = self.anno_object_scale_target {
+83
View File
@@ -32,6 +32,7 @@ impl OpenCADStudio {
Some(K::LayoutManager) => crate::tr!("modal", "layout-manager"),
Some(K::ScaleManager) => crate::tr!("modal", "scale-manager"),
Some(K::AnnoObjectScale) => crate::tr!("modal", "annotation-object-scale"),
Some(K::Hyperlink) => crate::t!("Hyperlink").into_owned(),
Some(K::InsertTable) => crate::t!("Insert Table").into_owned(),
Some(K::DataLinkManager) => crate::t!("Data Link Manager").into_owned(),
Some(K::DataExtraction) => crate::t!("Data Extraction Wizard").into_owned(),
@@ -1595,6 +1596,14 @@ impl OpenCADStudio {
)
},
),
super::super::ModalKind::Hyperlink => sized_flow(ex, 560, 260, |flow| {
hyperlink_dialog_window(
&self.hyperlink_editor_url,
&self.hyperlink_editor_description,
self.hyperlink_editor_mixed,
flow,
)
}),
super::super::ModalKind::AttributeEditor => {
let doc = &self.tabs[self.active_tab].scene.document;
let layers: Vec<String> = doc.layers.iter().map(|l| l.name.clone()).collect();
@@ -1727,6 +1736,80 @@ fn dialog_muted_text_style(theme: &Theme) -> iced::widget::text::Style {
}
}
fn hyperlink_dialog_window<'a>(
url: &'a str,
description: &'a str,
mixed: bool,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let label = |value: Cow<'static, str>| {
text(value)
.size(11)
.style(dialog_muted_text_style)
.width(90)
};
let mut items: Vec<Element<'a, Message>> = Vec::new();
if mixed {
items.push(
text(t!("Selected objects have different hyperlink values."))
.size(11)
.style(dialog_muted_text_style)
.into(),
);
items.push(Space::new().height(8).into());
}
items.push(
row![
label(t!("URL:")),
iced::widget::text_input("https://", url)
.on_input(Message::HyperlinkUrlChanged)
.size(13)
.padding([5, 8])
.width(Fill),
]
.spacing(8)
.align_y(iced::Alignment::Center)
.width(sizing.width)
.into(),
);
items.push(Space::new().height(8).into());
items.push(
row![
label(t!("Description:")),
iced::widget::text_input("", description)
.on_input(Message::HyperlinkDescriptionChanged)
.size(13)
.padding([5, 8])
.width(Fill),
]
.spacing(8)
.align_y(iced::Alignment::Center)
.width(sizing.width)
.into(),
);
items.push(Space::new().height(Fill).into());
items.push(
row![
dialog_button(t!("Remove"), Message::HyperlinkRemove, button::danger),
Space::new().width(Fill),
dialog_button(t!("Cancel"), Message::HyperlinkCancel, button::secondary),
Space::new().width(8),
dialog_button(t!("OK"), Message::HyperlinkApply, button::primary),
]
.align_y(iced::Alignment::Center)
.into(),
);
container(
column(items)
.spacing(0)
.width(sizing.width)
.height(sizing.height),
)
.style(dialog_body_style)
.padding([14, 16])
.into()
}
/// Compact Save-As options dialog: pick the format/version and a default file
/// name. The destination folder and overwrite confirmation come from the
/// native OS save dialog (native) or the browser download (web) that follows.
+5 -2
View File
@@ -30,7 +30,10 @@ pub fn general_section(entity: &EntityType) -> PropSection {
},
);
let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned();
let hyperlink = crate::scene::pe_url_description_of(entity)
.or_else(|| crate::scene::pe_url_of(entity))
.unwrap_or_default()
.to_owned();
let section = PropSection {
title: t!("General").into_owned(),
@@ -83,7 +86,7 @@ pub fn general_section(entity: &EntityType) -> PropSection {
Property {
label: t!("Hyperlink").into_owned(),
field: "hyperlink",
value: PropValue::PlainText(hyperlink),
value: PropValue::Hyperlink(hyperlink),
},
],
};
+1 -1
View File
@@ -51,7 +51,7 @@ mod preview;
mod project;
mod scene_markers;
mod selection;
pub(crate) use selection::pe_url_of;
pub(crate) use selection::{pe_url_description_of, pe_url_of};
pub mod parametric_constraints;
mod parametric_solve;
+3
View File
@@ -14,6 +14,9 @@ pub enum PropValue {
EditText(String),
/// Editable text that must not be expression-evaluated.
PlainText(String),
/// Hyperlink collection summary. The value is display-only in the row;
/// the adjacent collection button opens the URL/description editor.
Hyperlink(String),
/// Layer name — rendered as a combo_box.
LayerChoice(String),
/// Generic string choice rendered as a combo_box.
+19 -2
View File
@@ -12,6 +12,20 @@ pub(crate) fn pe_url_of(entity: &EntityType) -> Option<&str> {
.filter(|text| !text.is_empty())
}
/// Optional description stored after the URL in the standard PE_URL record.
pub(crate) fn pe_url_description_of(entity: &EntityType) -> Option<&str> {
let record = entity.common().extended_data.get_record("PE_URL")?;
record
.values
.iter()
.filter_map(|value| match value {
acadrust::xdata::XDataValue::String(text) => Some(text.trim()),
_ => None,
})
.nth(1)
.filter(|text| !text.is_empty())
}
impl Scene {
// ── Selection ─────────────────────────────────────────────────────────
/// Treat a classic LEADER and its attached annotation as one logical object.
@@ -670,7 +684,9 @@ impl Scene {
])
} else {
match prop.value {
PropValue::PlainText(_) => QSelectValueEditor::Text,
PropValue::PlainText(_) | PropValue::Hyperlink(_) => {
QSelectValueEditor::Text
}
PropValue::ReadOnly(ref value)
| PropValue::ReadOnlyWithTooltip { ref value, .. }
| PropValue::EditText(ref value) => {
@@ -872,7 +888,8 @@ impl Scene {
PropValue::ReadOnly(s)
| PropValue::ReadOnlyWithTooltip { value: s, .. }
| PropValue::EditText(s)
| PropValue::PlainText(s) => s,
| PropValue::PlainText(s)
| PropValue::Hyperlink(s) => s,
PropValue::LayerChoice(s) => s,
PropValue::Choice { selected, .. } => selected,
PropValue::EditChoice { value, .. } => value,
+13
View File
@@ -854,6 +854,7 @@ impl PropertiesPanel {
PropValue::EditText(val) | PropValue::PlainText(val) => {
self.render_edit_row(label, prop.field, val)
}
PropValue::Hyperlink(val) => render_hyperlink_row(label, val),
PropValue::ReadOnly(val) if prop.field == "annotative_scale" => {
render_annotative_scale_row(label, val)
}
@@ -1892,6 +1893,18 @@ fn render_annotative_scale_row<'a>(
prop_row_widget(label, controls.into())
}
fn render_hyperlink_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill);
let manage = button(text("...").size(FONT_SZ))
.on_press(Message::PropHyperlinkOpen)
.style(button::secondary)
.padding([2, 7]);
let controls = row![field, manage, iced::widget::space().width(10)]
.spacing(2)
.align_y(iced::Center)
.width(Length::Fill);
prop_row_widget(label, controls.into())
}
fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
// A read-only value is shown as a non-editable but selectable field: no
// on_input means the caret never appears, but the text can be selected