From 7215faae44645253c5a6abe19eb31914482fd565 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 23 Aug 2026 21:02:59 +0100 Subject: [PATCH] feat(properties): make inherited properties editable, align dropdowns, and switch transparency to EditChoice Enable in-place editing of inherited properties in the Properties panel: - Transparency: Switch to PropValue::EditChoice with ByLayer and ByBlock presets to allow direct typing of any numeric percentage value (0..90%) without combo_box autocomplete interference. - Select-all-on-click: Integrate EditChoice text inputs into build_field_key_map with dedicated widget IDs, preserving click-to-select-all across editable fields. - Material: Provide bounded preset choices (ByLayer, ByBlock, Global, and active custom material) to avoid scanning large drawing dictionaries. - Plot style: Implement mode-aware behavior with interactive Choice in STB mode and ReadOnlyWithTooltip in CTB mode. - Read-only tooltips: Introduce PropValue::ReadOnlyWithTooltip to provide contextual explanations for locked fields while preserving mouse text selection and clipboard copying. - Dropdown alignment & unified borders: Fix EditChoice internal input/caret background clipping so the container's 1px neutral/active border surrounds the entire control identically to Lineweight and text inputs. - Document defaults & aggregation: Synchronize document header defaults when editing with No selection, and aggregate heterogeneous multi-selections (*VARIES*). --- src/app/properties.rs | 201 ++++++++++++++++++++++++++------- src/app/update/command.rs | 72 ++++++++++++ src/io/pdf_export.rs | 28 +++-- src/scene/cache/properties.rs | 35 ++++-- src/scene/model/object.rs | 2 + src/scene/selection.rs | 2 + src/scene/view/dispatch.rs | 54 ++++++++- src/ui/color_select.rs | 39 ++++--- src/ui/properties.rs | 144 +++++++++++++++-------- tests/pdf_export_text_check.rs | 27 +++-- tests/text_font_rendering.rs | 1 + 11 files changed, 461 insertions(+), 144 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index abb9b0bc7..a7a5088b3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -273,18 +273,60 @@ impl OpenCADStudio { ), ), }, - read_only(t!("Transparency").as_ref(), "ByLayer".to_string()), + Property { + label: t!("Transparency").into_owned(), + field: "transparency", + value: PropValue::EditChoice { + value: "ByLayer".to_string(), + options: vec!["ByLayer".to_string(), "ByBlock".to_string()], + }, + }, read_only(t!("Thickness").as_ref(), format_length(header.thickness)), ], }, PropSection { title: t!("3D Visualization").into_owned(), - props: vec![read_only(t!("Material").as_ref(), material)], + props: vec![Property { + label: t!("Material").into_owned(), + field: "material", + value: PropValue::Choice { + selected: material.clone(), + options: { + let mut opts = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !opts.contains(&material) && !material.is_empty() { + opts.push(material.clone()); + } + opts + }, + }, + }], }, PropSection { title: t!("Plot style").into_owned(), props: vec![ - read_only(t!("Plot style").as_ref(), plot_style.to_string()), + Property { + label: t!("Plot style").into_owned(), + field: "plot_style", + value: if header.plotstyle_mode { + PropValue::Choice { + selected: plot_style.to_string(), + options: vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Normal".to_string(), + ], + } + } else { + PropValue::ReadOnlyWithTooltip { + value: plot_style.to_string(), + tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), + } + }, + }, read_only(t!("Plot style table").as_ref(), plot_table.clone()), read_only( t!("Plot table attached to").as_ref(), @@ -338,6 +380,23 @@ impl OpenCADStudio { ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), + choice_combos: sections + .iter() + .flat_map(|section| section.props.iter()) + .filter_map(|prop| match &prop.value { + crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + prop.field.to_string(), + iced::widget::combo_box::State::new( + options + .iter() + .cloned() + .map(ui::properties::LocalizedChoice::new) + .collect(), + ), + )), + _ => None, + }) + .collect(), sections, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new( @@ -381,17 +440,10 @@ impl OpenCADStudio { { let doc = &self.tabs[i].scene.document; let common = entity.common(); - let mat_names: Vec = doc - .objects - .iter() - .filter_map(|(_, o)| match o { - acadrust::objects::ObjectType::Material(m) => Some(m.name.clone()), - _ => None, - }) - .collect(); let selected = match common.material_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), + 2 => "Global".to_string(), _ => common .material_handle .and_then(|mh| { @@ -402,10 +454,16 @@ impl OpenCADStudio { _ => None, }) }) - .unwrap_or_else(|| "ByLayer".to_string()), + .unwrap_or_else(|| "Global".to_string()), }; - let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; - options.extend(mat_names); + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !options.contains(&selected) && !selected.is_empty() { + options.push(selected.clone()); + } for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == "material") @@ -665,32 +723,61 @@ impl OpenCADStudio { if self.tabs[i].scene.document.header.plotstyle_mode { let doc = &self.tabs[i].scene.document; let dict_h = doc.header.acad_plotstylename_dict_handle; + let common = entity.common(); + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Normal".to_string(), + ]; if let Some(dict) = crate::scene::annotative::as_dict(doc, dict_h) { - let common = entity.common(); - let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; - options.extend(dict.entries.iter().map(|(n, _)| n.clone())); - let selected = match common.plotstyle_flags { - 0 => "ByLayer".to_string(), - 1 => "ByBlock".to_string(), - _ => common - .plotstyle_handle - .and_then(|ph| { + for (n, _) in &dict.entries { + if !options.contains(n) { + options.push(n.clone()); + } + } + } + let selected = match common.plotstyle_flags { + 0 => "ByLayer".to_string(), + 1 => "ByBlock".to_string(), + 2 => "Normal".to_string(), + _ => common + .plotstyle_handle + .and_then(|ph| { + crate::scene::annotative::as_dict(doc, dict_h).and_then(|dict| { dict.entries .iter() .find(|(_, h)| *h == ph) .map(|(n, _)| n.clone()) }) - .unwrap_or_else(|| "ByLayer".to_string()), - }; - for section in sections.iter_mut() { - if let Some(row) = - section.props.iter_mut().find(|p| p.field == "plot_style") - { - row.value = crate::scene::model::object::PropValue::Choice { - selected: selected.clone(), - options: options.clone(), - }; - } + }) + .unwrap_or_else(|| "ByLayer".to_string()), + }; + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|p| p.field == "plot_style") + { + row.value = crate::scene::model::object::PropValue::Choice { + selected: selected.clone(), + options: options.clone(), + }; + } + } + } else { + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|p| p.field == "plot_style") + { + let common = entity.common(); + let plot_style_str = match common.plotstyle_flags { + 0 => "ByLayer", + 1 => "ByBlock", + 2 => "Normal", + _ => "ByColor", + }; + row.value = crate::scene::model::object::PropValue::ReadOnlyWithTooltip { + value: plot_style_str.to_string(), + tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), + }; } } } @@ -2017,6 +2104,7 @@ fn make_sections_read_only( { let text = match &property.value { PropValue::ReadOnly(value) + | PropValue::ReadOnlyWithTooltip { value, .. } | PropValue::EditText(value) | PropValue::PlainText(value) | PropValue::LayerChoice(value) @@ -2199,20 +2287,36 @@ fn merge_prop_value( options: other_options, .. }, - ) if options == other_options => PropValue::Choice { - selected: VARIES_LABEL.into(), - options: options.clone(), - }, + ) => { + let mut merged_options = options.clone(); + for opt in other_options { + if !merged_options.contains(opt) { + merged_options.push(opt.clone()); + } + } + PropValue::Choice { + selected: VARIES_LABEL.into(), + options: merged_options, + } + } ( PropValue::EditChoice { options, .. }, PropValue::EditChoice { options: other_options, .. }, - ) if options == other_options => PropValue::EditChoice { - value: VARIES_LABEL.into(), - options: options.clone(), - }, + ) => { + let mut merged_options = options.clone(); + for opt in other_options { + if !merged_options.contains(opt) { + merged_options.push(opt.clone()); + } + } + PropValue::EditChoice { + value: VARIES_LABEL.into(), + options: merged_options, + } + } (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } @@ -2222,6 +2326,19 @@ fn merge_prop_value( (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } + (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnlyWithTooltip { .. }) => { + PropValue::ReadOnlyWithTooltip { + value: VARIES_LABEL.into(), + tooltip: tooltip.clone(), + } + } + (PropValue::ReadOnly(_), PropValue::ReadOnlyWithTooltip { tooltip, .. }) + | (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnly(_)) => { + PropValue::ReadOnlyWithTooltip { + value: VARIES_LABEL.into(), + tooltip: tooltip.clone(), + } + } (PropValue::HatchPatternChoice(_), PropValue::HatchPatternChoice(_)) => { PropValue::HatchPatternChoice(VARIES_LABEL.into()) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 805c89598..83d007583 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2109,6 +2109,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.bump_entities(&changes); } } + } else if field == "transparency" { + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "transparency", &value); + } + } } else if field == "plot_style" { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's @@ -2139,6 +2148,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { common.plotstyle_flags = 1; common.plotstyle_handle = None; } + "Normal" => { + common.plotstyle_flags = 2; + common.plotstyle_handle = None; + } _ => { if let Some(h) = ph { common.plotstyle_flags = 3; @@ -2179,6 +2192,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { common.material_flags = 1; common.material_handle = None; } + "Global" => { + common.material_flags = 2; + common.material_handle = None; + } _ => { if let Some(h) = mat_handle { common.material_flags = 3; @@ -2233,11 +2250,60 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; + self.tabs[i].properties.edit_choice_open = false; if field == "spline_method" { self.tabs[i].properties.prop_vertex = 0; self.tabs[i].properties.prop_vertex_indicator_active = false; } self.refresh_properties(); + } else { + match field { + "transparency" => { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + "material" => { + let mat_handle: Option = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(h, o)| match o { + acadrust::objects::ObjectType::Material(m) if m.name == value => { + Some(*h) + } + _ => None, + }); + match value.as_str() { + "ByLayer" | "ByBlock" | "Global" => { + self.tabs[i].scene.document.header.current_material_handle = + acadrust::Handle::NULL; + } + _ => { + if let Some(h) = mat_handle { + self.tabs[i].scene.document.header.current_material_handle = + h; + } + } + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + "plot_style" => { + match value.as_str() { + "ByBlock" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 1 + } + "Normal" | "ByColor" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 2 + } + _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + _ => {} + } } Task::none() } @@ -2470,6 +2536,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.refresh_properties(); } + } else { + let _ = self.tabs[i] + .properties + .edit_buf + .remove(&crate::ui::properties::FieldKey::Geom(field)); + self.refresh_properties(); } Task::none() } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 8200a893e..ed1c160bd 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -1292,12 +1292,15 @@ mod tests { #[test] fn clip_and_scale_emit_pdf_bytes() { - let w = WireModel::solid( - "test".into(), - vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], - WireModel::WHITE, - false, - ); + let w = PlotWire { + wire: WireModel::solid( + "test".into(), + vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], + WireModel::WHITE, + false, + ), + draw_depth: 0.0, + }; let bytes = build_pdf( &[w], &[], @@ -1319,7 +1322,7 @@ mod tests { // Build a WireModel carrying the SDF glyph quads for `text` in the embedded // "txt" stroke font, laid out into the process-wide atlas emit_text reads. - fn text_wire(text: &str, origin: [f64; 3]) -> WireModel { + fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire { use crate::scene::pipeline::text_gpu::push_glyph_vertices; use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas}; let quads = { @@ -1329,9 +1332,12 @@ mod tests { assert!(!quads.is_empty(), "stroke glyphs laid out for {text:?}"); let mut verts = Vec::new(); push_glyph_vertices(&mut verts, &quads, origin, 1.0, [1.0, 0.0, 0.0, 1.0], 0.0); - WireModel { - text_verts: verts, - ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + PlotWire { + wire: WireModel { + text_verts: verts, + ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + }, + draw_depth: 0.0, } } @@ -1341,7 +1347,7 @@ mod tests { fn text_grows_the_pdf_vs_no_text() { let wire = text_wire("HELLO", [20.0, 20.0, 0.0]); let mut blank = wire.clone(); - blank.text_verts.clear(); + blank.wire.text_verts.clear(); let with_text = build_pdf( &[wire], diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index b65880a6b..fe199e881 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -10,15 +10,15 @@ pub fn general_section(entity: &EntityType) -> PropSection { } else { common.linetype.clone() }; - // Alpha 0 is the ByLayer default (Transparency::BY_LAYER); show it by name - // and fall back to a rounded percentage only for an explicit value. - let transp_display = if common.transparency.alpha() == 0 { - "ByLayer".to_string() - } else { - format!( + // Alpha 0 is ByLayer, Alpha 1 is ByBlock; show them by name and fall back + // to a rounded percentage for explicit values. + let transp_display = match common.transparency.alpha() { + 0 => "ByLayer".to_string(), + 1 => "ByBlock".to_string(), + alpha => format!( "{}", - (common.transparency.alpha() as f64 / 255.0 * 100.0).round() as u32 - ) + (alpha as f64 / 255.0 * 100.0).round() as u32 + ), }; // Hyperlink is stored in XDATA under the "PE_URL" application. @@ -81,7 +81,10 @@ pub fn general_section(entity: &EntityType) -> PropSection { Property { label: t!("Transparency").into_owned(), field: "transparency", - value: PropValue::EditText(transp_display), + value: PropValue::EditChoice { + value: transp_display, + options: vec!["ByLayer".to_string(), "ByBlock".to_string()], + }, }, Property { label: t!("Hyperlink").into_owned(), @@ -136,14 +139,26 @@ pub fn visualization_section(entity: &EntityType) -> Option { let material = match common.material_flags { 0 => "ByLayer", 1 => "ByBlock", + 2 => "Global", _ => "Custom", }; + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !options.iter().any(|o| o == material) && !material.is_empty() { + options.push(material.to_string()); + } Some(PropSection { title: t!("3D Visualization").into_owned(), props: vec![Property { label: t!("Material").into_owned(), field: "material", - value: PropValue::ReadOnly(material.into()), + value: PropValue::Choice { + selected: material.to_string(), + options, + }, }], }) } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index f688072c1..613a82f35 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -8,6 +8,8 @@ use glam::DVec3; pub enum PropValue { /// Read-only display text. ReadOnly(String), + /// Read-only display text with a tooltip explaining why it cannot be edited. + ReadOnlyWithTooltip { value: String, tooltip: String }, /// Editable numeric field. EditText(String), /// Editable text that must not be expression-evaluated. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index b58d85e2f..af107d9c1 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -463,6 +463,7 @@ impl Scene { match prop.value { PropValue::PlainText(_) => QSelectValueEditor::Text, PropValue::ReadOnly(ref value) + | PropValue::ReadOnlyWithTooltip { ref value, .. } | PropValue::EditText(ref value) => { let field = prop.field.to_ascii_lowercase(); let textual = [ @@ -660,6 +661,7 @@ impl Scene { .find(|p| p.field == field)?; Some(match prop.value { PropValue::ReadOnly(s) + | PropValue::ReadOnlyWithTooltip { value: s, .. } | PropValue::EditText(s) | PropValue::PlainText(s) => s, PropValue::LayerChoice(s) => s, diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index dfeaccbf7..34c441329 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -135,11 +135,55 @@ pub fn apply_common_prop(entity: &mut EntityType, field: &str, value: &str) { } } "transparency" => { - if let Ok(pct) = value.trim().parse::() { - let alpha = (pct.clamp(0.0, 100.0) / 100.0 * 255.0).round() as u8; - entity - .as_entity_mut() - .set_transparency(Transparency::new(alpha)); + let s = value.trim(); + if s.eq_ignore_ascii_case("ByLayer") { + entity.as_entity_mut().set_transparency(Transparency::new(0)); + } else if s.eq_ignore_ascii_case("ByBlock") { + entity.as_entity_mut().set_transparency(Transparency::new(1)); + } else { + let clean = s.trim_end_matches('%').trim(); + if let Ok(pct) = clean.parse::() { + let alpha = (pct.clamp(0.0, 90.0) / 100.0 * 255.0).round() as u8; + entity + .as_entity_mut() + .set_transparency(Transparency::new(alpha)); + } + } + } + "material" => { + let common = entity.common_mut(); + match value.trim() { + "ByLayer" => { + common.material_flags = 0; + common.material_handle = None; + } + "ByBlock" => { + common.material_flags = 1; + common.material_handle = None; + } + "Global" => { + common.material_flags = 2; + common.material_handle = None; + } + _ => {} + } + } + "plot_style" => { + let common = entity.common_mut(); + match value.trim() { + "ByLayer" => { + common.plotstyle_flags = 0; + common.plotstyle_handle = None; + } + "ByBlock" => { + common.plotstyle_flags = 1; + common.plotstyle_handle = None; + } + "Normal" => { + common.plotstyle_flags = 2; + common.plotstyle_handle = None; + } + _ => {} } } "thickness" => { diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index ba4d4c2a1..233ec87e4 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -132,6 +132,7 @@ pub fn color_selector<'a>( row![ swatch(cur_bg), text(cur_name).size(11), + iced::widget::Space::new().width(Length::Fill), crate::ui::icons::themed_arrow_toggle(open, 9.0), ] .spacing(5) @@ -149,22 +150,22 @@ pub fn color_selector<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() } }) - .padding(5) - .width(220); + .padding(2); // `DropDown` keeps the popup outside the surrounding form layout and // handles viewport placement, Escape, and outside-click dismissal. + // By omitting `.width(...)`, DropDown defaults to the exact pixel width + // of the underlay (`head`), matching the value column width and aligning flush. iced_aw::DropDown::new(head, popup, true) - .width(220) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(2.0) .on_dismiss(on_dismiss) @@ -187,8 +188,8 @@ fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { } /// The colour list shown inside a picker popup: named ACI colours (with -/// swatches) plus a "More…" entry that opens the full palette window. Shared by -/// `color_selector` and the ribbon's colour overlay. +/// swatches) plus a "Select Color..." entry that opens the full palette window. +/// Shared by `color_selector` and the ribbon's colour overlay. pub fn color_list<'a>( extras: ColorExtras, on_select: impl Fn(AcadColor) -> Message + 'a, @@ -219,7 +220,7 @@ pub fn color_list<'a>( list = list.push(named_row(AcadColor::Index(i))); } list = list.push( - button(text(t!("More…")).size(11)) + button(text(t!("Select Color...")).size(11)) .on_press(on_more) .style(list_row_style) .padding([2, 4]) @@ -232,17 +233,19 @@ pub fn color_list<'a>( pub fn drop_down_below<'a>( base: Element<'a, Message>, popup: Element<'a, Message>, - popup_width: Length, + popup_width: Option, popup_height: Length, on_dismiss: Message, ) -> Element<'a, Message> { - iced_aw::DropDown::new(base, popup, true) - .width(popup_width) + let mut dd = iced_aw::DropDown::new(base, popup, true) .height(popup_height) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(2.0) - .on_dismiss(on_dismiss) - .into() + .on_dismiss(on_dismiss); + if let Some(w) = popup_width { + dd = dd.width(w); + } + dd.into() } /// Full CAD indexed-colour page used by the standalone Select Color dialog. diff --git a/src/ui/properties.rs b/src/ui/properties.rs index e14838f34..1a036f4cb 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -308,9 +308,8 @@ pub fn active_key_focused( /// Precomputes the focused-id → [`FieldKey`] map for every editable value row /// in the given sections. Building it once when the panel's sections are /// assembled lets a `PropSyncActive` event map a focused text-input id back to -/// its field key in O(1) instead of re-scanning the sections. The block-Name -/// caret-dropdown (`EditChoice`) is editable but deliberately carries no id and -/// is excluded here, mirroring the renderer. +/// its field key in O(1) instead of re-scanning the sections, enabling +/// select-all-on-focus for both text inputs and edit-choices (e.g. transparency). pub fn build_field_key_map( sections: &[PropSection], ) -> HashMap { @@ -318,7 +317,9 @@ pub fn build_field_key_map( for section in sections { for prop in §ion.props { let key = match &prop.value { - PropValue::EditText(_) | PropValue::PlainText(_) => { + PropValue::EditText(_) + | PropValue::PlainText(_) + | PropValue::EditChoice { .. } => { Some(FieldKey::Geom(prop.field)) } PropValue::AttrText { tag, .. } => Some(FieldKey::Attr(tag.clone())), @@ -778,6 +779,9 @@ impl PropertiesPanel { render_annotative_scale_row(label, val) } PropValue::ReadOnly(val) => render_ro_row(label, val), + PropValue::ReadOnlyWithTooltip { value, tooltip } => { + render_ro_with_tooltip_row(label, value, tooltip) + } PropValue::HatchPatternChoice(current) => { self.render_hatch_pattern_row(label, current) } @@ -1102,20 +1106,21 @@ impl PropertiesPanel { entity_val: &'a str, options: &'a [String], ) -> Element<'a, Message> { - let typed = self.edit_buf.get(&FieldKey::Geom(field)); + let key = FieldKey::Geom(field); + let active = self.active_field.as_ref() == Some(&key); + let typed = self.edit_buf.get(&key); let display = typed.map(|s| s.as_str()).unwrap_or(entity_val); - // NOTE: unlike `render_edit_row` / `render_attr_row`, this input is - // deliberately NOT wrapped in a click-to-focus mouse_area. It is a - // caret-dropdown: clicking should place the caret / open the list, and - // the auto Select-all-on-focus would fight that. Don't "fix" it. let input = text_input("", display) + .id(prop_geom_field_id(field)) .on_input(move |v| Message::PropGeomInput { field, value: v }) .on_submit(Message::PropGeomCommit(field)) .size(FONT_SZ) .style(|theme: &Theme, status| text_input::Style { - // The wrapping container draws the border; keep the input flat - // so field + caret read as one control. + // The wrapping container draws the border and background; keep + // the input transparent and borderless so field + caret read as + // one continuous bordered box. + background: Background::Color(Color::TRANSPARENT), border: Border { color: Color::TRANSPARENT, width: 0.0, @@ -1123,55 +1128,69 @@ impl PropertiesPanel { }, ..text_input_style(theme, status) }) - .padding([3, 6]) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 6.0, + right: 2.0, + }) .width(Length::Fill); let caret = button( container(if self.edit_choice_open { - crate::ui::icons::themed_arrow_up(FONT_SZ) + crate::ui::icons::themed_arrow_up(9.0) } else { - crate::ui::icons::themed_arrow_down(FONT_SZ) + crate::ui::icons::themed_arrow_down(9.0) }) - .height(Length::Fill) .align_y(iced::Center), ) .on_press(Message::PropEditChoiceToggle) .style(|theme: &Theme, status| { let palette = theme.palette(); - let pair = match status { - button::Status::Hovered | button::Status::Pressed => palette.background.weak, - _ => palette.background.base, + let bg = match status { + button::Status::Hovered | button::Status::Pressed => { + Some(Background::Color(palette.background.weak.color)) + } + _ => None, + }; + let text_color = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak.text, + _ => palette.background.base.text, }; button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border::default(), - ..Default::default() + background: bg, + text_color, + border: Border::default(), + ..Default::default() } }) .padding(Padding { - top: 0.0, - bottom: 0.0, + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, left: 3.0, - right: 3.0, - }) - .height(Length::Fixed(ROW_H - 6.0)); + right: 4.0, + }); let head = container(row![input, caret].align_y(iced::Center)) - .style(|theme: &Theme| { + .style(move |theme: &Theme| { let palette = theme.palette(); + let border_color = if active { + palette.primary.base.color + } else { + palette.background.neutral.color + }; container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: border_color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() } }) .width(Length::Fill); if !self.edit_choice_open { - return prop_row_widget(label, head.into()); + return prop_row_with_active(label, head.into(), active); } // Open list: all definitions, filtered by any typed text. @@ -1194,19 +1213,18 @@ impl PropertiesPanel { } let popup = container(scrollable(list).height(Length::Shrink)) .style(container::bordered_box) - .padding(2) - .width(200) - .height(Length::Fit.max(220.0)); + .padding(2); - prop_row_widget( + prop_row_with_active( label, crate::ui::color_select::drop_down_below( head.into(), popup.into(), - Length::Fixed(200.0), + None, Length::Shrink, Message::PropEditChoiceToggle, ), + active, ) } @@ -1381,7 +1399,7 @@ impl PropertiesPanel { crate::ui::color_select::drop_down_below( head.into(), popup.into(), - Length::Fixed(PATTERN_PICKER_W), + Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), ), @@ -1658,7 +1676,12 @@ fn coord_suffix(label: &str) -> Option<(&str, usize)> { /// labelled " X", " Y" and optionally " Z". 0/1 = no group. fn coord_group_len(props: &[crate::scene::model::object::Property], idx: usize) -> usize { let groupable = |p: &crate::scene::model::object::Property| { - matches!(p.value, PropValue::EditText(_) | PropValue::ReadOnly(_)) + matches!( + p.value, + PropValue::EditText(_) + | PropValue::ReadOnly(_) + | PropValue::ReadOnlyWithTooltip { .. } + ) }; let Some((base, 0)) = coord_suffix(&props[idx].label) else { return 0; @@ -1697,7 +1720,9 @@ fn coord_component(label: &str) -> &'static str { /// EditText / ReadOnly — see `coord_group_len`). fn prop_text_value(prop: &crate::scene::model::object::Property) -> String { match &prop.value { - PropValue::EditText(s) | PropValue::ReadOnly(s) => s.clone(), + PropValue::EditText(s) + | PropValue::ReadOnly(s) + | PropValue::ReadOnlyWithTooltip { value: s, .. } => s.clone(), _ => String::new(), } } @@ -1811,6 +1836,31 @@ fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { prop_row_widget(label, field) } +fn render_ro_with_tooltip_row<'a>( + label: &'a str, + value: &'a str, + tooltip_text: &'a str, +) -> Element<'a, Message> { + let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); + let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top) + .gap(4.0) + .padding(6.0) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + text_color: Some(palette.background.base.text), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }); + prop_row_widget(label, wrapped.into()) +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) @@ -2093,8 +2143,10 @@ mod tests { assert_eq!(map.get(&attr_id), Some(&attr_edit_key("TITLE"))); assert!(active_key_focused(map.get(&attr_id), Some(&attr_id))); - // The block-Name caret-dropdown is editable but deliberately excluded. - assert_eq!(map.get(&prop_geom_field_id("name")), None); + // The caret-dropdown (EditChoice) is editable and maps to its field key. + let name_id = prop_geom_field_id("name"); + assert_eq!(map.get(&name_id), Some(&FieldKey::Geom("name"))); + assert!(active_key_focused(map.get(&name_id), Some(&name_id))); // Unknown or non-property ids map to nothing. assert_eq!(map.get(&prop_geom_field_id("nope")), None); diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index e8b1ac3b8..c15db1277 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -11,7 +11,7 @@ use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; -use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions}; +use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; use OpenCADStudio::scene::Scene; #[test] @@ -39,14 +39,10 @@ fn text_and_dim_reach_pdf_export() { .iter() .any(|w| w.name == want && !w.text_verts.is_empty()) }; - assert!( - has_text(text_h), - "TEXT carries no glyph quads to the exporter" - ); + assert!(has_text(text_h), "TEXT entity emitted no text glyphs"); assert!( has_text(dim_h), - "DIMENSION carries no glyph quads to the exporter — dim text would be \ - missing from the PDF (#385)" + "DIMENSION entity emitted no measurement-value text glyphs" ); // End-to-end: the same wire set with and without text. A private temp dir @@ -55,9 +51,17 @@ fn text_and_dim_reach_pdf_export() { let dir = std::env::temp_dir().join(format!("ocs385-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("temp dir"); + let plot_wires: Vec = wires + .iter() + .map(|w| PlotWire { + wire: w.clone(), + draw_depth: 0.0, + }) + .collect(); + let p_text = dir.join("with_text.pdf"); export_pdf( - &wires, + &plot_wires, &[], &[], 210.0, @@ -75,11 +79,10 @@ fn text_and_dim_reach_pdf_export() { let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); - let stripped: Vec<_> = wires - .iter() - .cloned() + let stripped: Vec<_> = plot_wires + .into_iter() .map(|mut w| { - w.text_verts.clear(); + w.wire.text_verts.clear(); w }) .collect(); diff --git a/tests/text_font_rendering.rs b/tests/text_font_rendering.rs index b0287a784..eb741373c 100644 --- a/tests/text_font_rendering.rs +++ b/tests/text_font_rendering.rs @@ -77,6 +77,7 @@ fn expand_block_mtext( }, 0, false, + false, 1.0, None, None,