Pin Iced 0.15 so dialogs can measure content before distributing the resolved frame across child panels. Keep the web plugin notice compact and non-resizable.\n\nCloses #582
1751 lines
62 KiB
Rust
1751 lines
62 KiB
Rust
//! Properties panel — OpenCADStudio-style editable object properties.
|
||
//!
|
||
//! Shows two sections (General + Geometry) for the selected entity.
|
||
//! • Layer → combo_box (options from document layer table)
|
||
//! • Color → inline color picker (ByLayer / ByBlock / ACI palette)
|
||
//! • Lineweight → combo_box (standard CAD lineweight list)
|
||
//! • Linetype → read-only for now
|
||
//! • Geometry → text_input per coordinate / dimension field
|
||
|
||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||
use std::{fmt, sync::Arc};
|
||
|
||
use crate::ui::ROW_H;
|
||
use acadrust::types::{Color as AcadColor, LineWeight};
|
||
use acadrust::Handle;
|
||
use iced::widget::{
|
||
button, canvas, column, combo_box, container, mouse_area, row, scrollable, text, text_input,
|
||
};
|
||
use iced::{
|
||
mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme,
|
||
};
|
||
|
||
// ── Row-height-derived constants ─────────────────────────────────────────
|
||
const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px
|
||
const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; // fills combo to ROW_H
|
||
const SWATCH_SZ: f32 = ROW_H * 0.54; // ≈14 px color swatch
|
||
const PATTERN_CARD_W: f32 = 158.0;
|
||
const PATTERN_PREVIEW_H: f32 = 58.0;
|
||
|
||
use crate::app::Message;
|
||
use crate::scene::model::object::{PropSection, PropValue};
|
||
|
||
const VARIES_LABEL: &str = "*VARIES*";
|
||
|
||
// ── Linetype item (name + ASCII art for combo_box) ───────────────────────
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct LinetypeItem {
|
||
pub name: String,
|
||
pub art: String,
|
||
}
|
||
|
||
impl fmt::Display for LinetypeItem {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
if self.art.is_empty() {
|
||
write!(f, "{}", self.name)
|
||
} else {
|
||
write!(f, "{} {}", self.name, self.art)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Lineweight wrapper (needs ToString for combo_box) ─────────────────────
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct LwItem(pub LineWeight);
|
||
|
||
impl fmt::Display for LwItem {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self.0 {
|
||
LineWeight::ByLayer => write!(f, "ByLayer"),
|
||
LineWeight::ByBlock => write!(f, "ByBlock"),
|
||
LineWeight::Default => write!(f, "Default"),
|
||
LineWeight::Value(v) => write!(f, "{:.2} mm", v as f64 / 100.0),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct SelectionGroup {
|
||
pub label: String,
|
||
pub handles: Vec<Handle>,
|
||
}
|
||
|
||
impl fmt::Display for SelectionGroup {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
write!(f, "{}", self.label)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct HatchPatternPreview {
|
||
pattern: crate::scene::model::hatch_model::HatchPattern,
|
||
}
|
||
|
||
impl canvas::Program<Message> for HatchPatternPreview {
|
||
type State = ();
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &iced::Renderer,
|
||
theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<canvas::Geometry> {
|
||
use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
|
||
|
||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||
let palette = theme.palette();
|
||
let pad = 4.0;
|
||
let sample = canvas::Path::rectangle(
|
||
Point::new(pad, pad),
|
||
Size::new(
|
||
(bounds.width - pad * 2.0).max(0.0),
|
||
(bounds.height - pad * 2.0).max(0.0),
|
||
),
|
||
);
|
||
frame.fill(&sample, palette.background.base.color);
|
||
|
||
match &self.pattern {
|
||
HatchPattern::Solid => {
|
||
frame.fill(&sample, palette.background.base.text.scale_alpha(0.72));
|
||
}
|
||
HatchPattern::Gradient { .. } => {
|
||
frame.fill(&sample, palette.primary.weak.color);
|
||
}
|
||
HatchPattern::Pattern(_) => {
|
||
let model = HatchModel {
|
||
world_origin: [0.0, 0.0],
|
||
boundary: Arc::new(vec![
|
||
[pad, pad],
|
||
[bounds.width - pad, pad],
|
||
[bounds.width - pad, bounds.height - pad],
|
||
[pad, bounds.height - pad],
|
||
]),
|
||
boundary_wcs: None,
|
||
pattern: self.pattern.clone(),
|
||
name: String::new(),
|
||
color: [1.0; 4],
|
||
angle_offset: 0.0,
|
||
scale: hatch_preview_scale(&self.pattern),
|
||
draw_depth: 0.0,
|
||
};
|
||
let stroke = canvas::Stroke::default()
|
||
.with_color(palette.background.base.text)
|
||
.with_width(1.0);
|
||
for segment in model.pattern_segments() {
|
||
frame.stroke(
|
||
&canvas::Path::line(
|
||
Point::new(segment[0][0] as f32, bounds.height - segment[0][1] as f32),
|
||
Point::new(segment[1][0] as f32, bounds.height - segment[1][1] as f32),
|
||
),
|
||
stroke.clone(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
frame.stroke(
|
||
&sample,
|
||
canvas::Stroke::default()
|
||
.with_color(palette.background.neutral.color)
|
||
.with_width(1.0),
|
||
);
|
||
vec![frame.into_geometry()]
|
||
}
|
||
}
|
||
|
||
fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) -> f32 {
|
||
use crate::scene::model::hatch_model::HatchPattern;
|
||
|
||
let HatchPattern::Pattern(families) = pattern else {
|
||
return 1.0;
|
||
};
|
||
let spacing = families
|
||
.iter()
|
||
.filter_map(|family| {
|
||
let spacing = family.dy.abs();
|
||
(spacing > 1.0e-4).then_some(spacing)
|
||
})
|
||
.fold(f32::INFINITY, f32::min);
|
||
if spacing.is_finite() {
|
||
(8.0 / spacing).clamp(0.01, 100.0)
|
||
} else {
|
||
1.0
|
||
}
|
||
}
|
||
|
||
fn hatch_pattern_matches(
|
||
entry: &crate::scene::model::hatch_patterns::PatternEntry,
|
||
search: &str,
|
||
) -> bool {
|
||
let query = search.trim();
|
||
query.is_empty()
|
||
|| entry.name.to_lowercase().contains(&query.to_lowercase())
|
||
|| entry.description.to_lowercase().contains(&query.to_lowercase())
|
||
}
|
||
|
||
pub(crate) fn filtered_hatch_patterns(
|
||
search: &str,
|
||
) -> Vec<&'static crate::scene::model::hatch_patterns::PatternEntry> {
|
||
crate::scene::model::hatch_patterns::catalog()
|
||
.iter()
|
||
.filter(|entry| hatch_pattern_matches(entry, search))
|
||
.collect()
|
||
}
|
||
|
||
/// All standard CAD lineweight options for the combobox.
|
||
pub fn lw_options() -> Vec<LwItem> {
|
||
[
|
||
LineWeight::ByLayer,
|
||
LineWeight::ByBlock,
|
||
LineWeight::Default,
|
||
LineWeight::Value(0),
|
||
LineWeight::Value(5),
|
||
LineWeight::Value(9),
|
||
LineWeight::Value(13),
|
||
LineWeight::Value(15),
|
||
LineWeight::Value(18),
|
||
LineWeight::Value(20),
|
||
LineWeight::Value(25),
|
||
LineWeight::Value(30),
|
||
LineWeight::Value(35),
|
||
LineWeight::Value(40),
|
||
LineWeight::Value(50),
|
||
LineWeight::Value(53),
|
||
LineWeight::Value(60),
|
||
LineWeight::Value(70),
|
||
LineWeight::Value(80),
|
||
LineWeight::Value(90),
|
||
LineWeight::Value(100),
|
||
LineWeight::Value(106),
|
||
LineWeight::Value(120),
|
||
LineWeight::Value(140),
|
||
LineWeight::Value(158),
|
||
LineWeight::Value(200),
|
||
LineWeight::Value(211),
|
||
]
|
||
.iter()
|
||
.copied()
|
||
.map(LwItem)
|
||
.collect()
|
||
}
|
||
|
||
/// Edit-buffer key for a block attribute value, keyed by its tag. Kept in one
|
||
/// place so the live-input handler and the row renderer agree. The `\x01`
|
||
/// sentinel guarantees no collision with a geometry field's `&'static str` key.
|
||
pub fn attr_edit_key(tag: &str) -> String {
|
||
format!("\x01attr\x01{tag}")
|
||
}
|
||
|
||
// ── PropertiesPanel ───────────────────────────────────────────────────────
|
||
|
||
#[derive(Clone)]
|
||
pub struct PropertiesPanel {
|
||
pub sections: Vec<PropSection>,
|
||
pub title: String,
|
||
pub selection_groups: Vec<SelectionGroup>,
|
||
pub selected_group: Option<SelectionGroup>,
|
||
/// Linetype items (name + ASCII art) from the document — used for combo_box options.
|
||
pub linetype_items: Vec<LinetypeItem>,
|
||
pub selection_group_combo: combo_box::State<SelectionGroup>,
|
||
pub choice_combos: HashMap<String, combo_box::State<String>>,
|
||
pub layer_combo: combo_box::State<String>,
|
||
pub lineweight_combo: combo_box::State<LwItem>,
|
||
pub linetype_combo: combo_box::State<LinetypeItem>,
|
||
/// Whether the visual hatch-pattern picker is open.
|
||
pub hatch_pattern_picker_open: bool,
|
||
/// Case-insensitive filter for the visual hatch-pattern picker.
|
||
pub hatch_pattern_search: String,
|
||
/// Keyboard/hover focus inside the filtered visual pattern grid.
|
||
pub hatch_pattern_focus: usize,
|
||
/// In-progress text edits keyed by `field` name.
|
||
pub edit_buf: HashMap<String, String>,
|
||
/// Entity handles this panel was built for. `refresh_properties` compares
|
||
/// it against the new selection to decide whether an uncommitted `edit_buf`
|
||
/// may carry over: same selection → keep (survive a commit-triggered
|
||
/// rebuild); different selection → drop, so a stale value can't display or
|
||
/// commit onto a different entity (e.g. two title blocks sharing a `REV1`
|
||
/// tag).
|
||
pub source_handles: Vec<Handle>,
|
||
/// Whether the quick color picker dropdown is open.
|
||
pub color_picker_open: bool,
|
||
/// Whether the full 16×16 ACI palette is expanded inside the color picker.
|
||
pub color_palette_open: bool,
|
||
/// Whether the MTEXT background-colour picker dropdown is open. Separate
|
||
/// from `color_picker_open` so the entity colour and the background colour
|
||
/// pickers are independent.
|
||
pub bg_color_picker_open: bool,
|
||
/// Field name of the generic per-field colour picker currently open (e.g. a
|
||
/// hatch gradient colour), or `None`. Keeps each field's picker independent.
|
||
pub open_color_field: Option<String>,
|
||
/// Which vertex a multi-vertex entity (polyline) is focused on — driven by
|
||
/// the Current Vertex ◀ / ▶ stepper. Reset to 0 when the selection changes.
|
||
pub prop_vertex: usize,
|
||
/// Coordinate groups ("Position", "Scale", …) the user expanded into their
|
||
/// component X/Y/Z rows. Collapsed by default; keyed `section:base` and
|
||
/// carried across panel rebuilds so the state survives edits and selection
|
||
/// changes.
|
||
pub expanded_groups: HashSet<String>,
|
||
/// Whether the editable-dropdown (block Name) option list is open.
|
||
pub edit_choice_open: bool,
|
||
}
|
||
|
||
impl Default for PropertiesPanel {
|
||
fn default() -> Self {
|
||
Self {
|
||
sections: vec![],
|
||
title: String::new(),
|
||
selection_groups: vec![],
|
||
selected_group: None,
|
||
linetype_items: vec![],
|
||
selection_group_combo: combo_box::State::new(vec![]),
|
||
choice_combos: HashMap::default(),
|
||
layer_combo: combo_box::State::new(vec![]),
|
||
lineweight_combo: combo_box::State::new(lw_options()),
|
||
linetype_combo: combo_box::State::new(vec![]),
|
||
hatch_pattern_picker_open: false,
|
||
hatch_pattern_search: String::new(),
|
||
hatch_pattern_focus: 0,
|
||
edit_buf: HashMap::default(),
|
||
source_handles: vec![],
|
||
color_picker_open: false,
|
||
color_palette_open: false,
|
||
bg_color_picker_open: false,
|
||
open_color_field: None,
|
||
prop_vertex: 0,
|
||
expanded_groups: HashSet::default(),
|
||
edit_choice_open: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PropertiesPanel {
|
||
pub fn empty() -> Self {
|
||
Self {
|
||
title: "No Selection".into(),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
pub fn selected_handles(&self) -> Vec<Handle> {
|
||
self.selected_group
|
||
.as_ref()
|
||
.map(|group| group.handles.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub fn view(&self) -> Element<'_, Message> {
|
||
// ── Header ──────────────────────────────────────────────────────────
|
||
let header = container(text("Properties").size(12))
|
||
.style(|theme: &Theme| container::Style {
|
||
background: Some(Background::Color(
|
||
theme.palette().background.weak.color,
|
||
)),
|
||
..Default::default()
|
||
})
|
||
.width(Length::Fill)
|
||
.padding([6, 10]);
|
||
|
||
// ── Title bar (entity type / "No Selection") ─────────────────────
|
||
let title_content: Element<'_, Message> = if self.selection_groups.is_empty() {
|
||
text(crate::ui::text_util::elide(&self.title, 34))
|
||
.size(FONT_SZ)
|
||
.style(muted_text_style)
|
||
.into()
|
||
} else {
|
||
combo_box(
|
||
&self.selection_group_combo,
|
||
"",
|
||
self.selected_group.as_ref(),
|
||
Message::PropSelectionGroupChanged,
|
||
)
|
||
.size(FONT_SZ)
|
||
.padding([2, 6])
|
||
.input_style(combo_input_style)
|
||
.on_open(Message::PropColorPickerClose)
|
||
.width(Length::Fill)
|
||
.into()
|
||
};
|
||
|
||
let title_bar = container(title_content)
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.weakest.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.width(Length::Fill)
|
||
.padding([4, 10]);
|
||
|
||
// ── Content ─────────────────────────────────────────────────────────
|
||
let content: Element<'_, Message> = if self.sections.is_empty() {
|
||
container(
|
||
text("Select an object to view properties")
|
||
.size(10)
|
||
.style(hint_text_style),
|
||
)
|
||
.padding([10, 10])
|
||
.into()
|
||
} else {
|
||
let mut col = column![].spacing(0);
|
||
for section in &self.sections {
|
||
col = col.push(self.render_section(section));
|
||
}
|
||
scrollable(col).into()
|
||
};
|
||
|
||
container(column![header, title_bar, content])
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.base.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.width(250)
|
||
.height(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
/// Compact floating panel for Quick Properties: the title plus the same
|
||
/// editable section rows as the docked panel, sized to its content.
|
||
/// Returns `None` when nothing is selected.
|
||
pub fn quick_view(&self) -> Option<Element<'_, Message>> {
|
||
if self.sections.is_empty() {
|
||
return None;
|
||
}
|
||
let title = container(
|
||
text(crate::ui::text_util::elide(&self.title, 34))
|
||
.size(FONT_SZ)
|
||
.style(muted_text_style),
|
||
)
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.weakest.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.width(Length::Fill)
|
||
.padding([4, 10]);
|
||
|
||
let mut col = column![title].spacing(0);
|
||
for section in &self.sections {
|
||
col = col.push(self.render_section(section));
|
||
}
|
||
|
||
Some(
|
||
container(col)
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.base.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 3.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.width(230)
|
||
.into(),
|
||
)
|
||
}
|
||
|
||
// ── Section renderer ──────────────────────────────────────────────────
|
||
|
||
fn render_section<'a>(&'a self, section: &'a PropSection) -> Element<'a, Message> {
|
||
// Section header
|
||
let hdr = container(text(§ion.title).size(10))
|
||
.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: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.width(Length::Fill)
|
||
.padding([3, 8]);
|
||
|
||
let mut col = column![hdr].spacing(0);
|
||
|
||
// Consecutive "<Base> X / <Base> Y [/ <Base> Z]" text rows collapse
|
||
// into one clickable summary row; clicking expands the components.
|
||
let mut idx = 0;
|
||
while idx < section.props.len() {
|
||
let group_len = if section.title == "View" {
|
||
0
|
||
} else {
|
||
coord_group_len(§ion.props, idx)
|
||
};
|
||
if group_len >= 2 {
|
||
let base = coord_base(§ion.props[idx].label);
|
||
let key = format!("{}:{}", section.title, base);
|
||
let expanded = self.expanded_groups.contains(&key);
|
||
let joined = section.props[idx..idx + group_len]
|
||
.iter()
|
||
.map(prop_text_value)
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
col = col.push(render_group_row(base, key, expanded, joined));
|
||
if expanded {
|
||
for prop in §ion.props[idx..idx + group_len] {
|
||
col = col.push(self.render_prop_row(prop, coord_component(&prop.label)));
|
||
}
|
||
}
|
||
idx += group_len;
|
||
} else {
|
||
let prop = §ion.props[idx];
|
||
col = col.push(self.render_prop_row(prop, &prop.label));
|
||
idx += 1;
|
||
}
|
||
}
|
||
|
||
col.into()
|
||
}
|
||
|
||
/// Render one property row with an explicit display label (the grouped
|
||
/// coordinate rows shorten "Position X" to "X").
|
||
fn render_prop_row<'a>(
|
||
&'a self,
|
||
prop: &'a crate::scene::model::object::Property,
|
||
label: &'a str,
|
||
) -> Element<'a, Message> {
|
||
match &prop.value {
|
||
PropValue::ColorChoice(color) => self.render_color_row(label, prop.field, *color),
|
||
PropValue::ColorVaries => self.render_color_varies_row(label),
|
||
PropValue::LayerChoice(layer) => self.render_layer_row(label, layer),
|
||
PropValue::LwChoice(lw) => self.render_lw_row(label, *lw),
|
||
PropValue::LwVaries => self.render_lw_varies_row(label),
|
||
PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt),
|
||
PropValue::Choice { selected, options } => {
|
||
self.render_choice_row(label, prop.field, selected, options)
|
||
}
|
||
PropValue::EditChoice { value, options } => {
|
||
self.render_edit_choice_row(label, prop.field, value, options)
|
||
}
|
||
PropValue::BoolToggle { field, value } => render_bool_row(label, *field, *value),
|
||
PropValue::Stepper { display, .. } => render_stepper_row(label, display),
|
||
PropValue::EditText(val) => self.render_edit_row(label, prop.field, val),
|
||
PropValue::ReadOnly(val) => render_ro_row(label, val),
|
||
PropValue::HatchPatternChoice(current) => {
|
||
self.render_hatch_pattern_row(label, current)
|
||
}
|
||
PropValue::AttrText { tag, value } => self.render_attr_row(tag, value),
|
||
}
|
||
}
|
||
|
||
// ── Layer row (combo_box) ─────────────────────────────────────────────
|
||
|
||
fn render_layer_row<'a>(&'a self, label: &'a str, current: &'a str) -> Element<'a, Message> {
|
||
let selected = if current == VARIES_LABEL {
|
||
None
|
||
} else {
|
||
Some(current.to_string())
|
||
};
|
||
let combo = combo_box(
|
||
&self.layer_combo,
|
||
VARIES_LABEL,
|
||
selected.as_ref(),
|
||
Message::PropLayerChanged,
|
||
)
|
||
.size(FONT_SZ)
|
||
.padding(Padding {
|
||
top: COMBO_PAD_V,
|
||
bottom: COMBO_PAD_V,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
})
|
||
.input_style(combo_input_style)
|
||
.on_open(Message::PropColorPickerClose)
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, combo.into())
|
||
}
|
||
|
||
// ── Color row (custom picker) ─────────────────────────────────────────
|
||
|
||
fn render_color_row<'a>(
|
||
&'a self,
|
||
label: &'a str,
|
||
field: &'static str,
|
||
color: AcadColor,
|
||
) -> Element<'a, Message> {
|
||
// MTEXT background colour uses its own picker state + messages so it
|
||
// routes to `background_color`, not the entity's main colour.
|
||
if field == "background_color" {
|
||
let selector = crate::ui::color_select::color_selector(
|
||
color,
|
||
self.bg_color_picker_open,
|
||
crate::ui::color_select::ColorExtras {
|
||
by_layer: true,
|
||
by_block: true,
|
||
},
|
||
Message::PropBgColorChanged,
|
||
Message::PropBgColorPickerToggle,
|
||
// "More Colors…" opens the full palette window targeting the
|
||
// background colour — this used to just close the picker
|
||
// (#415).
|
||
Message::OpenColorWindow(crate::app::ColorPickTarget::PropertiesBg),
|
||
);
|
||
return prop_row_widget(label, selector);
|
||
}
|
||
// Generic per-field colour picker — routes to the named field, not the
|
||
// entity's main colour. Used by hatch gradient colours and the dim-line
|
||
// colour override (Leader / Dimension). Dim colours legitimately take
|
||
// ByLayer / ByBlock; gradient colours do not.
|
||
if field == "gradient_color_1" || field == "gradient_color_2" || field == "dim_line_color" {
|
||
let open = self.open_color_field.as_deref() == Some(field);
|
||
let fsel = field.to_string();
|
||
let extras = if field == "dim_line_color" {
|
||
crate::ui::color_select::ColorExtras {
|
||
by_layer: true,
|
||
by_block: true,
|
||
}
|
||
} else {
|
||
crate::ui::color_select::ColorExtras {
|
||
by_layer: false,
|
||
by_block: false,
|
||
}
|
||
};
|
||
let selector = crate::ui::color_select::color_selector(
|
||
color,
|
||
open,
|
||
extras,
|
||
move |c| Message::PropColorFieldChanged {
|
||
field: fsel.clone(),
|
||
color: c,
|
||
},
|
||
Message::PropColorFieldToggle(field.to_string()),
|
||
Message::PropColorFieldToggle(field.to_string()),
|
||
);
|
||
return prop_row_widget(label, selector);
|
||
}
|
||
let selector = crate::ui::color_select::color_selector(
|
||
color,
|
||
self.color_picker_open,
|
||
crate::ui::color_select::ColorExtras {
|
||
by_layer: true,
|
||
by_block: true,
|
||
},
|
||
Message::PropColorChanged,
|
||
Message::PropColorPickerToggle,
|
||
Message::OpenColorWindow(crate::app::ColorPickTarget::Properties),
|
||
);
|
||
prop_row_widget(label, selector)
|
||
}
|
||
|
||
fn render_color_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> {
|
||
let color_btn = button(
|
||
row![
|
||
container(text("?").size(10))
|
||
.style(move |theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.strong.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 2.0.into()
|
||
},
|
||
text_color: Some(palette.background.strong.text),
|
||
..Default::default()
|
||
}
|
||
})
|
||
.width(SWATCH_SZ)
|
||
.height(SWATCH_SZ)
|
||
.align_x(iced::Center)
|
||
.align_y(iced::Center),
|
||
text(VARIES_LABEL).size(FONT_SZ),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Center),
|
||
)
|
||
.on_press(Message::PropColorPickerToggle)
|
||
.style(combo_btn_style)
|
||
.padding(Padding {
|
||
top: COMBO_PAD_V,
|
||
bottom: COMBO_PAD_V,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
})
|
||
.width(Length::Fill);
|
||
|
||
let color_row = prop_row_widget(label, color_btn.into());
|
||
if self.color_picker_open {
|
||
column![color_row, self.render_color_picker()]
|
||
.spacing(0)
|
||
.into()
|
||
} else {
|
||
color_row
|
||
}
|
||
}
|
||
|
||
fn render_color_picker(&self) -> Element<'_, Message> {
|
||
color_picker_dropdown(
|
||
self.color_palette_open,
|
||
Message::PropColorPaletteToggle,
|
||
Some(Message::PropColorChanged(AcadColor::ByLayer)),
|
||
Some(Message::PropColorChanged(AcadColor::ByBlock)),
|
||
|aci| Message::PropColorChanged(AcadColor::Index(aci)),
|
||
)
|
||
}
|
||
|
||
// ── Lineweight row (combo_box) ────────────────────────────────────────
|
||
|
||
fn render_lw_row<'a>(&'a self, label: &'a str, lw: LineWeight) -> Element<'a, Message> {
|
||
let selected = LwItem(lw);
|
||
let combo = combo_box(
|
||
&self.lineweight_combo,
|
||
"",
|
||
Some(&selected),
|
||
|item: LwItem| Message::PropLwChanged(item.0),
|
||
)
|
||
.size(FONT_SZ)
|
||
.padding(Padding {
|
||
top: COMBO_PAD_V,
|
||
bottom: COMBO_PAD_V,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
})
|
||
.input_style(combo_input_style)
|
||
.on_open(Message::PropColorPickerClose)
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, combo.into())
|
||
}
|
||
|
||
fn render_lw_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> {
|
||
let combo = combo_box(
|
||
&self.lineweight_combo,
|
||
VARIES_LABEL,
|
||
None,
|
||
|item: LwItem| Message::PropLwChanged(item.0),
|
||
)
|
||
.size(FONT_SZ)
|
||
.padding(Padding {
|
||
top: COMBO_PAD_V,
|
||
bottom: COMBO_PAD_V,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
})
|
||
.input_style(combo_input_style)
|
||
.on_open(Message::PropColorPickerClose)
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, combo.into())
|
||
}
|
||
|
||
// ── Linetype row (combo_box) ──────────────────────────────────────────
|
||
|
||
fn render_linetype_row<'a>(&'a self, label: &'a str, current: &'a str) -> Element<'a, Message> {
|
||
// Normalise: empty string = "ByLayer"
|
||
let display = if current.is_empty() {
|
||
"ByLayer"
|
||
} else {
|
||
current
|
||
};
|
||
let selected = self
|
||
.linetype_items
|
||
.iter()
|
||
.find(|item| item.name.eq_ignore_ascii_case(display))
|
||
.cloned();
|
||
let combo = combo_box(
|
||
&self.linetype_combo,
|
||
VARIES_LABEL,
|
||
selected.as_ref(),
|
||
|item: LinetypeItem| Message::PropLinetypeChanged(item.name),
|
||
)
|
||
.size(FONT_SZ)
|
||
.padding(Padding {
|
||
top: COMBO_PAD_V,
|
||
bottom: COMBO_PAD_V,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
})
|
||
.input_style(combo_input_style)
|
||
.on_open(Message::PropColorPickerClose)
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, combo.into())
|
||
}
|
||
|
||
fn render_choice_row<'a>(
|
||
&'a self,
|
||
label: &'a str,
|
||
field: &'static str,
|
||
current: &'a str,
|
||
_options: &'a [String],
|
||
) -> Element<'a, Message> {
|
||
let Some(state) = self.choice_combos.get(field) else {
|
||
return render_ro_row(label, current);
|
||
};
|
||
|
||
let selected = if current == VARIES_LABEL {
|
||
None
|
||
} else {
|
||
Some(current.to_string())
|
||
};
|
||
let combo = combo_box(state, VARIES_LABEL, selected.as_ref(), move |value| {
|
||
Message::PropGeomChoiceChanged { field, value }
|
||
})
|
||
.size(FONT_SZ)
|
||
.padding(Padding {
|
||
top: COMBO_PAD_V,
|
||
bottom: COMBO_PAD_V,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
})
|
||
.input_style(combo_input_style)
|
||
.on_open(Message::PropColorPickerClose)
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, combo.into())
|
||
}
|
||
|
||
// ── Editable geometry row (text_input) ────────────────────────────────
|
||
|
||
fn render_edit_row<'a>(
|
||
&'a self,
|
||
label: &'a str,
|
||
field: &'static str,
|
||
entity_val: &'a str,
|
||
) -> Element<'a, Message> {
|
||
let display = self
|
||
.edit_buf
|
||
.get(field)
|
||
.map(|s| s.as_str())
|
||
.unwrap_or(entity_val);
|
||
|
||
let ti = text_input("", display)
|
||
.on_input(move |v| Message::PropGeomInput { field, value: v })
|
||
.on_submit(Message::PropGeomCommit(field))
|
||
.size(FONT_SZ)
|
||
.style(text_input_style)
|
||
.padding([3, 6])
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, ti.into())
|
||
}
|
||
|
||
/// Editable dropdown row (block reference Name): a text field with a caret
|
||
/// button in one bordered control. Typing + Enter commits through the
|
||
/// normal PropGeomCommit path (existing name → re-point, new name →
|
||
/// rename); the caret opens a floating list of the definitions (always
|
||
/// downward, via the shared `floating_below` mechanic) and picking one
|
||
/// applies through PropGeomChoiceChanged. Typed text filters the list.
|
||
fn render_edit_choice_row<'a>(
|
||
&'a self,
|
||
label: &'a str,
|
||
field: &'static str,
|
||
entity_val: &'a str,
|
||
options: &'a [String],
|
||
) -> Element<'a, Message> {
|
||
let typed = self.edit_buf.get(field);
|
||
let display = typed.map(|s| s.as_str()).unwrap_or(entity_val);
|
||
|
||
let input = text_input("", display)
|
||
.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.
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..text_input_style(theme, status)
|
||
})
|
||
.padding([3, 6])
|
||
.width(Length::Fill);
|
||
let caret = button(
|
||
container(if self.edit_choice_open {
|
||
crate::ui::icons::themed_arrow_up(FONT_SZ)
|
||
} else {
|
||
crate::ui::icons::themed_arrow_down(FONT_SZ)
|
||
})
|
||
.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,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(pair.color)),
|
||
text_color: pair.text,
|
||
border: Border::default(),
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding(Padding {
|
||
top: 0.0,
|
||
bottom: 0.0,
|
||
left: 3.0,
|
||
right: 3.0,
|
||
})
|
||
.height(Length::Fixed(ROW_H - 6.0));
|
||
let head = container(row![input, caret].align_y(iced::Center))
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
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()
|
||
}
|
||
})
|
||
.width(Length::Fill);
|
||
|
||
if !self.edit_choice_open {
|
||
return prop_row_widget(label, head.into());
|
||
}
|
||
|
||
// Open list: all definitions, filtered by any typed text.
|
||
let filter = typed.map(|s| s.to_lowercase());
|
||
let mut list = column![].spacing(1);
|
||
for opt in options {
|
||
if let Some(f) = &filter {
|
||
if !opt.to_lowercase().contains(f.as_str()) {
|
||
continue;
|
||
}
|
||
}
|
||
let value = opt.clone();
|
||
list = list.push(
|
||
button(text(opt.as_str()).size(FONT_SZ))
|
||
.on_press(Message::PropGeomChoiceChanged { field, value })
|
||
.style(button::subtle)
|
||
.padding([2, 6])
|
||
.width(Length::Fill),
|
||
);
|
||
}
|
||
let popup = container(scrollable(list).height(Length::Shrink))
|
||
.style(container::bordered_box)
|
||
.padding(2)
|
||
.width(200)
|
||
.height(Length::Fit.max(220.0));
|
||
|
||
prop_row_widget(
|
||
label,
|
||
crate::ui::color_select::floating_below(head.into(), popup.into()),
|
||
)
|
||
}
|
||
|
||
/// One editable row for a block attribute: the tag is the row label and the
|
||
/// text box edits the value. Routing rides the tag (a runtime string), so
|
||
/// this uses the dedicated `PropAttr*` messages instead of the geometry
|
||
/// path whose field key is `&'static str`. The row label is the tag itself.
|
||
fn render_attr_row<'a>(&'a self, tag: &'a str, entity_val: &'a str) -> Element<'a, Message> {
|
||
let key = attr_edit_key(tag);
|
||
let display = self
|
||
.edit_buf
|
||
.get(&key)
|
||
.map(|s| s.as_str())
|
||
.unwrap_or(entity_val);
|
||
|
||
let tag_for_input = tag.to_string();
|
||
let ti = text_input("", display)
|
||
.on_input(move |v| Message::PropAttrInput {
|
||
tag: tag_for_input.clone(),
|
||
value: v,
|
||
})
|
||
.on_submit(Message::PropAttrCommit(tag.to_string()))
|
||
.size(FONT_SZ)
|
||
.style(text_input_style)
|
||
.padding([3, 6])
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(tag, ti.into())
|
||
}
|
||
|
||
fn render_hatch_pattern_row<'a>(
|
||
&'a self,
|
||
label: &'a str,
|
||
current: &'a str,
|
||
) -> Element<'a, Message> {
|
||
let head = button(
|
||
row![
|
||
text(crate::ui::text_util::elide(current, 16))
|
||
.size(FONT_SZ)
|
||
.width(Length::Fill),
|
||
if self.hatch_pattern_picker_open {
|
||
crate::ui::icons::themed_arrow_up(FONT_SZ)
|
||
} else {
|
||
crate::ui::icons::themed_arrow_down(FONT_SZ)
|
||
},
|
||
]
|
||
.align_y(iced::Center),
|
||
)
|
||
.on_press(Message::PropHatchPatternPickerToggle(current.to_string()))
|
||
.style(move |theme: &Theme, status| {
|
||
let palette = theme.palette();
|
||
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
|
||
button::Style {
|
||
background: Some(Background::Color(if hovered {
|
||
palette.background.weak.color
|
||
} else {
|
||
palette.background.base.color
|
||
})),
|
||
text_color: palette.background.base.text,
|
||
border: Border {
|
||
color: if self.hatch_pattern_picker_open {
|
||
palette.primary.base.color
|
||
} else {
|
||
palette.background.neutral.color
|
||
},
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding([COMBO_PAD_V, 6.0])
|
||
.width(Length::Fill);
|
||
|
||
if !self.hatch_pattern_picker_open {
|
||
return prop_row_widget(label, head.into());
|
||
}
|
||
|
||
let search = text_input("Search patterns…", &self.hatch_pattern_search)
|
||
.id(iced::widget::Id::new("hatch-pattern-search"))
|
||
.on_input(Message::PropHatchPatternSearchChanged)
|
||
.on_submit(Message::PropHatchPatternConfirm)
|
||
.size(FONT_SZ)
|
||
.padding([5, 7])
|
||
.width(Length::Fill);
|
||
|
||
let mut grid = column![].spacing(6);
|
||
let visible = filtered_hatch_patterns(&self.hatch_pattern_search);
|
||
for (row_index, pair) in visible.chunks(2).enumerate() {
|
||
let mut cards = row![].spacing(6);
|
||
for (column_index, entry) in pair.iter().enumerate() {
|
||
let index = row_index * 2 + column_index;
|
||
let selected = current.eq_ignore_ascii_case(&entry.name);
|
||
let focused = self.hatch_pattern_focus == index;
|
||
let name = entry.name.clone();
|
||
let preview = canvas(HatchPatternPreview {
|
||
pattern: entry.gpu.clone(),
|
||
})
|
||
.width(Length::Fill)
|
||
.height(PATTERN_PREVIEW_H);
|
||
let card = button(
|
||
column![
|
||
preview,
|
||
container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ))
|
||
.width(Length::Fill)
|
||
.align_x(iced::Center),
|
||
]
|
||
.spacing(3),
|
||
)
|
||
.on_press(Message::PropHatchPatternChanged(name))
|
||
.style(move |theme: &Theme, status| {
|
||
let palette = theme.palette();
|
||
let hovered =
|
||
matches!(status, button::Status::Hovered | button::Status::Pressed);
|
||
let pair = if selected {
|
||
palette.primary.weak
|
||
} else if hovered || focused {
|
||
palette.background.strong
|
||
} else {
|
||
palette.background.weak
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(pair.color)),
|
||
text_color: pair.text,
|
||
border: Border {
|
||
color: if selected || focused {
|
||
palette.primary.base.color
|
||
} else {
|
||
palette.background.neutral.color
|
||
},
|
||
width: if selected || focused { 2.0 } else { 1.0 },
|
||
radius: 4.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding(5)
|
||
.width(PATTERN_CARD_W);
|
||
cards = cards.push(
|
||
mouse_area(card).on_enter(Message::PropHatchPatternFocus(index)),
|
||
);
|
||
}
|
||
grid = grid.push(cards);
|
||
}
|
||
|
||
let results: Element<'_, Message> = if visible.is_empty() {
|
||
container(
|
||
text("No matching patterns")
|
||
.size(FONT_SZ)
|
||
.style(hint_text_style),
|
||
)
|
||
.padding(12)
|
||
.width(Length::Fill)
|
||
.center_x(Length::Fill)
|
||
.into()
|
||
} else {
|
||
scrollable(grid)
|
||
.height(Length::Fixed(300.0))
|
||
.width(Length::Fill)
|
||
.into()
|
||
};
|
||
let popup = container(column![search, results].spacing(7))
|
||
.style(container::bordered_box)
|
||
.padding(8)
|
||
.width(348)
|
||
.height(Length::Fit.max(360.0));
|
||
|
||
prop_row_widget(
|
||
label,
|
||
crate::ui::color_select::floating_below(head.into(), popup.into()),
|
||
)
|
||
}
|
||
}
|
||
|
||
// ── Shared color picker widget ────────────────────────────────────────────
|
||
|
||
/// Builds the color picker dropdown content (standard swatches + optional
|
||
/// ByLayer/ByBlock + "More Colors…" expanding to full ACI palette).
|
||
/// Use this from both the Properties panel and the Layer Manager.
|
||
pub fn color_picker_dropdown<'a>(
|
||
palette_open: bool,
|
||
palette_toggle_msg: Message,
|
||
by_layer_msg: Option<Message>,
|
||
by_block_msg: Option<Message>,
|
||
on_aci: impl Fn(u8) -> Message + 'a,
|
||
) -> Element<'a, Message> {
|
||
// ByLayer / ByBlock row (optional)
|
||
let extras: Option<Element<'a, Message>> = match (by_layer_msg, by_block_msg) {
|
||
(Some(bl), Some(bb)) => Some(
|
||
row![
|
||
picker_text_btn("ByLayer", bl),
|
||
picker_text_btn("ByBlock", bb)
|
||
]
|
||
.spacing(4)
|
||
.into(),
|
||
),
|
||
(Some(bl), None) => Some(picker_text_btn("ByLayer", bl)),
|
||
(None, Some(bb)) => Some(picker_text_btn("ByBlock", bb)),
|
||
(None, None) => None,
|
||
};
|
||
|
||
// 9 standard ACI swatches (1-9)
|
||
let standard: Element<'a, Message> = (1u8..=9u8)
|
||
.fold(row![].spacing(2), |r, idx| {
|
||
let c = AcadColor::Index(idx);
|
||
let (bg, _) = acad_color_display(c);
|
||
let msg = on_aci(idx);
|
||
r.push(
|
||
button(text("").width(18).height(18))
|
||
.on_press(msg)
|
||
.style(move |theme: &Theme, status| button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
border: Border {
|
||
color: if matches!(status, button::Status::Hovered) {
|
||
theme.palette().primary.base.color
|
||
} else {
|
||
theme.palette().background.neutral.color
|
||
},
|
||
width: if matches!(status, button::Status::Hovered) {
|
||
1.5
|
||
} else {
|
||
1.0
|
||
},
|
||
radius: 2.0.into(),
|
||
},
|
||
text_color: theme.palette().background.base.text,
|
||
..Default::default()
|
||
})
|
||
.padding(0),
|
||
)
|
||
})
|
||
.into();
|
||
|
||
// "More Colors…" toggle button
|
||
let more_btn = button(
|
||
row![
|
||
if palette_open {
|
||
crate::ui::icons::themed_arrow_up(9.0)
|
||
} else {
|
||
crate::ui::icons::themed_arrow_down(9.0)
|
||
},
|
||
text(if palette_open { "Less" } else { "More Colors…" })
|
||
.size(10)
|
||
.style(hint_text_style),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Center),
|
||
)
|
||
.on_press(palette_toggle_msg)
|
||
.style(button::subtle)
|
||
.padding([2, 6])
|
||
.width(Length::Fill);
|
||
|
||
let inner = if let Some(e) = extras {
|
||
column![e, standard, more_btn].spacing(4)
|
||
} else {
|
||
column![standard, more_btn].spacing(4)
|
||
};
|
||
|
||
let mut col = column![container(inner)
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.base.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into()
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding([6, 8])
|
||
.width(Length::Fill)]
|
||
.spacing(0);
|
||
|
||
// Full ACI palette (expanded)
|
||
if palette_open {
|
||
const COLS: u16 = 16;
|
||
let mut rows = column![].spacing(1);
|
||
let mut idx: u16 = 1;
|
||
while idx <= 255 {
|
||
let mut r = row![].spacing(1);
|
||
for _ in 0..COLS {
|
||
if idx > 255 {
|
||
break;
|
||
}
|
||
let ci = idx as u8;
|
||
let (bg, _) = acad_color_display(AcadColor::Index(ci));
|
||
let msg = on_aci(ci);
|
||
r = r.push(
|
||
button(text("").width(12).height(12))
|
||
.on_press(msg)
|
||
.style(move |theme: &Theme, status| button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
border: Border {
|
||
color: if matches!(status, button::Status::Hovered) {
|
||
theme.palette().primary.base.color
|
||
} else {
|
||
theme.palette().background.neutral.color
|
||
},
|
||
width: if matches!(status, button::Status::Hovered) {
|
||
1.5
|
||
} else {
|
||
1.0
|
||
},
|
||
radius: 1.0.into(),
|
||
},
|
||
text_color: theme.palette().background.base.text,
|
||
..Default::default()
|
||
})
|
||
.padding(0),
|
||
);
|
||
idx += 1;
|
||
}
|
||
rows = rows.push(r);
|
||
}
|
||
col = col.push(
|
||
container(scrollable(rows).height(160))
|
||
.style(|theme: &Theme| {
|
||
let palette = theme.palette();
|
||
container::Style {
|
||
background: Some(Background::Color(palette.background.base.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding([4, 6])
|
||
.width(Length::Fill),
|
||
);
|
||
}
|
||
|
||
col.into()
|
||
}
|
||
|
||
// ── Standalone helpers ────────────────────────────────────────────────────
|
||
|
||
/// A boolean toggle button row (for "Invisible" etc.).
|
||
fn render_stepper_row<'a>(label: &'a str, display: &'a str) -> Element<'a, Message> {
|
||
let arrow = |glyph: &'static str, delta: i8| {
|
||
button(text(glyph).size(FONT_SZ))
|
||
.on_press(Message::PropVertexStep(delta))
|
||
.padding([0, 6])
|
||
.style(|theme: &Theme, status| {
|
||
let palette = theme.palette();
|
||
let pair = match status {
|
||
button::Status::Hovered | button::Status::Pressed => palette.background.weak,
|
||
_ => palette.background.base,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(pair.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
text_color: pair.text,
|
||
..Default::default()
|
||
}
|
||
})
|
||
};
|
||
let widget = iced::widget::row![
|
||
arrow("◀", -1),
|
||
text(display)
|
||
.size(FONT_SZ)
|
||
.width(Length::Fill)
|
||
.align_x(iced::Center),
|
||
arrow("▶", 1),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Center);
|
||
prop_row_widget(label, widget.into())
|
||
}
|
||
|
||
fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Element<'a, Message> {
|
||
let btn_label = if value { "Yes" } else { "No" };
|
||
let btn =
|
||
button(
|
||
text(btn_label)
|
||
.size(FONT_SZ)
|
||
.style(move |theme: &Theme| iced::widget::text::Style {
|
||
color: value.then_some(theme.palette().warning.base.color),
|
||
}),
|
||
)
|
||
.on_press(Message::PropBoolToggle(field))
|
||
.style(move |theme: &Theme, status| {
|
||
let palette = theme.palette();
|
||
let pair = match status {
|
||
button::Status::Hovered | button::Status::Pressed => palette.background.weak,
|
||
_ => palette.background.base,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(pair.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
text_color: if value {
|
||
palette.warning.base.color
|
||
} else {
|
||
pair.text
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding([2, 6])
|
||
.width(Length::Fill);
|
||
|
||
prop_row_widget(label, btn.into())
|
||
}
|
||
|
||
// ── Collapsible coordinate groups (Position / Start / Scale …) ────────────
|
||
|
||
/// The X/Y/Z suffix rank of a coordinate row label, with its base ("Position
|
||
/// X" → ("Position", 0)). `None` for non-coordinate labels.
|
||
fn coord_suffix(label: &str) -> Option<(&str, usize)> {
|
||
for (rank, suf) in [" X", " Y", " Z"].iter().enumerate() {
|
||
if let Some(base) = label.strip_suffix(suf) {
|
||
if !base.is_empty() {
|
||
return Some((base, rank));
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Length of the coordinate group starting at `idx`: consecutive text rows
|
||
/// 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(_))
|
||
};
|
||
let Some((base, 0)) = coord_suffix(&props[idx].label) else {
|
||
return 0;
|
||
};
|
||
if !groupable(&props[idx]) {
|
||
return 0;
|
||
}
|
||
let mut len = 1;
|
||
while idx + len < props.len() && len < 3 {
|
||
match coord_suffix(&props[idx + len].label) {
|
||
Some((b, r)) if b == base && r == len && groupable(&props[idx + len]) => len += 1,
|
||
_ => break,
|
||
}
|
||
}
|
||
if len >= 2 {
|
||
len
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
|
||
fn coord_base(label: &str) -> &str {
|
||
coord_suffix(label).map(|(b, _)| b).unwrap_or(label)
|
||
}
|
||
|
||
/// Short component label for an expanded row ("Position X" → indented "X").
|
||
fn coord_component(label: &str) -> &'static str {
|
||
match coord_suffix(label) {
|
||
Some((_, 0)) => " X",
|
||
Some((_, 1)) => " Y",
|
||
_ => " Z",
|
||
}
|
||
}
|
||
|
||
/// Display string of a text-valued property (grouped rows are always
|
||
/// 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(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
/// The collapsed summary row of a coordinate group. The expand arrow leads
|
||
/// the label cell (clicking the cell toggles); the value cell is the same
|
||
/// read-only selectable field every other read-only row uses.
|
||
fn render_group_row(
|
||
base: &str,
|
||
key: String,
|
||
expanded: bool,
|
||
joined: String,
|
||
) -> Element<'_, Message> {
|
||
let label_btn = button(
|
||
container(
|
||
row![
|
||
if expanded {
|
||
crate::ui::icons::themed_arrow_down(FONT_SZ)
|
||
} else {
|
||
crate::ui::icons::themed_arrow_right(FONT_SZ)
|
||
},
|
||
text(crate::ui::text_util::elide(base, 16))
|
||
.size(FONT_SZ)
|
||
.style(muted_text_style),
|
||
]
|
||
.spacing(4)
|
||
.align_y(iced::Center),
|
||
)
|
||
.height(Length::Fill)
|
||
.align_y(iced::Center),
|
||
)
|
||
.on_press(Message::PropGroupToggle(key))
|
||
.style(button::subtle)
|
||
.padding(Padding {
|
||
top: 0.0,
|
||
bottom: 0.0,
|
||
left: 4.0,
|
||
right: 6.0,
|
||
})
|
||
.width(Length::Fill)
|
||
.height(Length::Fixed(ROW_H));
|
||
let label_col = container(label_btn)
|
||
.style(|theme: &Theme| container::Style {
|
||
background: Some(Background::Color(
|
||
theme.palette().background.weakest.color,
|
||
)),
|
||
..Default::default()
|
||
})
|
||
.width(Length::FillPortion(5))
|
||
.height(Length::Fixed(ROW_H))
|
||
.align_y(iced::Center);
|
||
|
||
// text_input copies the value, so the locally-built `joined` is fine here.
|
||
let value_field = text_input("", &joined)
|
||
.on_input(|_| Message::Noop)
|
||
.size(FONT_SZ)
|
||
.style(ro_input_style)
|
||
.padding([3, 6])
|
||
.width(Length::Fill);
|
||
let value_col = container(value_field)
|
||
.style(|theme: &Theme| container::Style {
|
||
background: Some(Background::Color(
|
||
theme.palette().background.base.color,
|
||
)),
|
||
..Default::default()
|
||
})
|
||
.width(Length::FillPortion(6))
|
||
.height(Length::Fixed(ROW_H))
|
||
.align_y(iced::Center)
|
||
.padding(Padding {
|
||
top: 0.0,
|
||
bottom: 0.0,
|
||
left: 2.0,
|
||
right: 2.0,
|
||
});
|
||
|
||
container(row![label_col, value_col])
|
||
.height(Length::Fixed(ROW_H))
|
||
.style(|theme: &Theme| container::Style {
|
||
border: Border {
|
||
color: theme.palette().background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.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 text field:
|
||
// the user can select the text (which carries the full, un-truncated
|
||
// value) and copy it with Ctrl+C. Keystrokes route to Noop, so the value
|
||
// can be selected/copied but never edited.
|
||
let field = text_input("", value)
|
||
.on_input(|_| Message::Noop)
|
||
.size(FONT_SZ)
|
||
.style(ro_input_style)
|
||
.padding([3, 6])
|
||
.width(Length::Fill);
|
||
prop_row_widget(label, field.into())
|
||
}
|
||
|
||
/// Build a label | widget property row.
|
||
fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> {
|
||
let label_col = container(
|
||
text(crate::ui::text_util::elide(label, 18))
|
||
.size(FONT_SZ)
|
||
.style(muted_text_style),
|
||
)
|
||
.style(|theme: &Theme| container::Style {
|
||
background: Some(Background::Color(
|
||
theme.palette().background.weakest.color,
|
||
)),
|
||
..Default::default()
|
||
})
|
||
.width(Length::FillPortion(5))
|
||
.height(Length::Fixed(ROW_H))
|
||
.align_y(iced::Center)
|
||
.padding(Padding {
|
||
top: 0.0,
|
||
bottom: 0.0,
|
||
left: 6.0,
|
||
right: 6.0,
|
||
});
|
||
let value_col = container(widget)
|
||
.style(|theme: &Theme| container::Style {
|
||
background: Some(Background::Color(
|
||
theme.palette().background.base.color,
|
||
)),
|
||
..Default::default()
|
||
})
|
||
.width(Length::FillPortion(6))
|
||
.height(Length::Fixed(ROW_H))
|
||
.align_y(iced::Center)
|
||
.padding(Padding {
|
||
top: 0.0,
|
||
bottom: 0.0,
|
||
left: 2.0,
|
||
right: 2.0,
|
||
});
|
||
container(row![label_col, value_col])
|
||
.height(Length::Fixed(ROW_H))
|
||
.style(|theme: &Theme| container::Style {
|
||
border: Border {
|
||
color: theme.palette().background.neutral.color,
|
||
width: 1.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
/// A plain text button used inside the color picker for ByLayer / ByBlock.
|
||
fn picker_text_btn(label: &str, msg: Message) -> Element<'_, Message> {
|
||
button(text(label).size(FONT_SZ))
|
||
.on_press(msg)
|
||
.style(button::secondary)
|
||
.padding([2, 8])
|
||
.into()
|
||
}
|
||
|
||
// ── Color display helper ──────────────────────────────────────────────────
|
||
|
||
/// Returns an (iced::Color swatch_bg, display_label) pair for an AcadColor.
|
||
pub fn acad_color_display(c: AcadColor) -> (Color, &'static str) {
|
||
match c {
|
||
AcadColor::None => (Color::TRANSPARENT, "None"),
|
||
AcadColor::ByLayer => (
|
||
Color {
|
||
r: 0.35,
|
||
g: 0.35,
|
||
b: 0.35,
|
||
a: 1.0,
|
||
},
|
||
"ByLayer",
|
||
),
|
||
AcadColor::ByBlock => (
|
||
Color {
|
||
r: 0.25,
|
||
g: 0.25,
|
||
b: 0.45,
|
||
a: 1.0,
|
||
},
|
||
"ByBlock",
|
||
),
|
||
AcadColor::Index(i) => {
|
||
let (r, g, b) = acadrust::types::aci_table::aci_to_rgb(i).unwrap_or((200, 200, 200));
|
||
(
|
||
Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0),
|
||
aci_label(i),
|
||
)
|
||
}
|
||
AcadColor::Rgb { r, g, b } => (
|
||
Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0),
|
||
"Custom",
|
||
),
|
||
}
|
||
}
|
||
|
||
fn aci_label(idx: u8) -> &'static str {
|
||
match idx {
|
||
1 => "Red",
|
||
2 => "Yellow",
|
||
3 => "Green",
|
||
4 => "Cyan",
|
||
5 => "Blue",
|
||
6 => "Magenta",
|
||
7 => "White",
|
||
8 => "Dark Gray",
|
||
9 => "Light Gray",
|
||
_ => "Index",
|
||
}
|
||
}
|
||
|
||
// ── Widget style helpers ──────────────────────────────────────────────────
|
||
|
||
fn combo_btn_style(theme: &Theme, status: button::Status) -> button::Style {
|
||
let palette = theme.palette();
|
||
let pair = match status {
|
||
button::Status::Hovered | button::Status::Pressed => palette.background.weak,
|
||
_ => palette.background.base,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(pair.color)),
|
||
border: Border {
|
||
color: palette.background.neutral.color,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
text_color: pair.text,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||
let palette = theme.palette();
|
||
let border_color = match status {
|
||
text_input::Status::Focused { .. } => palette.primary.base.color,
|
||
_ => palette.background.neutral.color,
|
||
};
|
||
text_input::Style {
|
||
background: Background::Color(palette.background.base.color),
|
||
border: Border {
|
||
color: border_color,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
icon: Color::TRANSPARENT,
|
||
placeholder: palette.background.base.text.scale_alpha(0.48),
|
||
value: palette.background.base.text,
|
||
selection: palette.primary.base.color.scale_alpha(0.5),
|
||
}
|
||
}
|
||
|
||
fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||
text_input_style(theme, status)
|
||
}
|
||
|
||
/// Style for a read-only-but-selectable value field: flat (no input box or
|
||
/// focus highlight, so it reads as plain text, unlike the bordered editable
|
||
/// fields) yet with a visible selection colour so Ctrl+C copy is discoverable.
|
||
fn ro_input_style(theme: &Theme, _status: text_input::Status) -> text_input::Style {
|
||
let palette = theme.palette();
|
||
text_input::Style {
|
||
background: Background::Color(palette.background.base.color),
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: 0.0.into(),
|
||
},
|
||
icon: Color::TRANSPARENT,
|
||
placeholder: palette.background.base.text.scale_alpha(0.48),
|
||
value: palette.background.base.text,
|
||
selection: palette.primary.base.color.scale_alpha(0.5),
|
||
}
|
||
}
|
||
|
||
fn muted_text_style(theme: &Theme) -> iced::widget::text::Style {
|
||
iced::widget::text::Style {
|
||
color: Some(theme.palette().background.base.text.scale_alpha(0.72)),
|
||
}
|
||
}
|
||
|
||
fn hint_text_style(theme: &Theme) -> iced::widget::text::Style {
|
||
iced::widget::text::Style {
|
||
color: Some(theme.palette().background.base.text.scale_alpha(0.48)),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{hatch_pattern_matches, hatch_preview_scale};
|
||
|
||
#[test]
|
||
fn hatch_picker_filters_names_and_descriptions() {
|
||
let ansi31 = crate::scene::model::hatch_patterns::find("ANSI31").unwrap();
|
||
|
||
assert!(hatch_pattern_matches(ansi31, "ansi"));
|
||
assert!(hatch_pattern_matches(ansi31, &ansi31.description));
|
||
assert!(!hatch_pattern_matches(ansi31, "definitely-not-a-pattern"));
|
||
}
|
||
|
||
#[test]
|
||
fn hatch_preview_scale_is_finite_and_visible() {
|
||
let ansi31 = crate::scene::model::hatch_patterns::find("ANSI31").unwrap();
|
||
let scale = hatch_preview_scale(&ansi31.gpu);
|
||
|
||
assert!(scale.is_finite());
|
||
assert!((0.01..=100.0).contains(&scale));
|
||
}
|
||
}
|