From 6a1c649016f5c1e0ab073a4f155e846e18279a3a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:17:25 +0300 Subject: [PATCH] Fix Properties hyperlink collection editor --- src/app/commands/display.rs | 16 +++-- src/app/control/actions.rs | 6 +- src/app/mod.rs | 20 +++++++ src/app/properties.rs | 7 ++- src/app/update/mod.rs | 107 ++++++++++++++++++++++++++++++++++ src/app/view/modal.rs | 83 ++++++++++++++++++++++++++ src/scene/cache/properties.rs | 7 ++- src/scene/mod.rs | 2 +- src/scene/model/object.rs | 3 + src/scene/selection.rs | 21 ++++++- src/ui/properties.rs | 13 +++++ 11 files changed, 272 insertions(+), 13 deletions(-) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index a7604403e..8e7ad669b 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -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 (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()); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 8c45f84d6..425e930c7 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -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, diff --git a/src/app/mod.rs b/src/app/mod.rs index fab5a9ecb..6e8d0f0df 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, + /// Selection and staged values owned by the Properties hyperlink dialog. + hyperlink_editor_handles: Vec, + hyperlink_editor_url: String, + hyperlink_editor_description: String, + hyperlink_editor_mixed: bool, + hyperlink_editor_dirty: bool, pending_startup_modals: std::collections::VecDeque, /// 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, diff --git a/src/app/properties.rs b/src/app/properties.rs index f7438bcc0..1f3e94bda 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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, _ => {} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c88d9ffdf..42bd43e2d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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 != ¤t) { + 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 { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 157334d76..a826f3d79 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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 = 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> = 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. diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 4490e24ea..98a1e23d4 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -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), }, ], }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 57e7e9264..b1710333a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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; diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 6c3f2eb39..df84137e9 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -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. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 7469aed92..c07394e5d 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -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, diff --git a/src/ui/properties.rs b/src/ui/properties.rs index d5b233e06..5f41b1847 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -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