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*).
This commit is contained in:
Karim Jerbi 2026-08-23 21:02:59 +01:00
commit 7215faae44
11 changed files with 461 additions and 144 deletions

View file

@ -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<String> = 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())
}

View file

@ -2109,6 +2109,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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<Message> {
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<Message> {
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<Message> {
}
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<acadrust::Handle> = 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<Message> {
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()
}

View file

@ -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],

View file

@ -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<PropSection> {
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,
},
}],
})
}

View file

@ -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.

View file

@ -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,

View file

@ -135,11 +135,55 @@ pub fn apply_common_prop(entity: &mut EntityType, field: &str, value: &str) {
}
}
"transparency" => {
if let Ok(pct) = value.trim().parse::<f64>() {
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::<f64>() {
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" => {

View file

@ -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<Length>,
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.

View file

@ -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<iced::widget::Id, FieldKey> {
@ -318,7 +317,9 @@ pub fn build_field_key_map(
for section in sections {
for prop in &section.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 "<Base> X", "<Base> Y" and optionally "<Base> 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);

View file

@ -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<PlotWire> = 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();

View file

@ -77,6 +77,7 @@ fn expand_block_mtext(
},
0,
false,
false,
1.0,
None,
None,