forked from 1b-agent/opencadstudio-upstream
The node library leaves the overlay and becomes a dock panel (PanelId:: NodeGraph), docked on the right the first time the graph opens and movable like any other panel; closing it closes the graph. It follows the block palette's layout: title bar, then a search box with New / Open / Save beside it, then the categories, of which only Objects starts open. Entries are clicked (placed at the canvas top-left) or dragged onto the canvas. The title bar (title, auto-collapse pin, close, drag to re-dock), the toolbar icon button and the panel frame were copied across Properties, Block Palette, External References and Browser; they are now dock::title_bar, dock::tool_button and dock::frame, used by all of them. Block Palette's two tool buttons gain tooltips, and External References' Auto / Close tooltips are now translated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2266 lines
84 KiB
Rust
2266 lines
84 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 codec::types::{Color as AcadColor, LineWeight};
|
|
use codec::Handle;
|
|
use iced::widget::{
|
|
button, canvas, column, combo_box, container, mouse_area, row, scrollable, text, text_input,
|
|
tooltip,
|
|
};
|
|
use iced::{
|
|
mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme,
|
|
};
|
|
use crate::t;
|
|
|
|
// ── 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 PATTERN_CARD_W: f32 = 158.0;
|
|
const PATTERN_PREVIEW_H: f32 = 58.0;
|
|
const PATTERN_PICKER_W: f32 = 348.0;
|
|
const PATTERN_PICKER_H: f32 = 720.0;
|
|
const LINETYPE_MENU_W: f32 = 220.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,
|
|
}
|
|
|
|
pub fn linetype_display_name(name: &str) -> String {
|
|
if name.is_empty() || name.eq_ignore_ascii_case("ByLayer") {
|
|
crate::t!("ByLayer").into_owned()
|
|
} else if name.eq_ignore_ascii_case("ByBlock") {
|
|
crate::t!("ByBlock").into_owned()
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for LinetypeItem {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let name = linetype_display_name(&self.name);
|
|
if self.art.is_empty() {
|
|
write!(f, "{name}")
|
|
} else {
|
|
write!(f, "{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, "{}", crate::t!("ByLayer")),
|
|
LineWeight::ByBlock => write!(f, "{}", crate::t!("ByBlock")),
|
|
LineWeight::Default => write!(f, "{}", crate::t!("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 {
|
|
pattern_origin: None,
|
|
render_instance: None,
|
|
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,
|
|
fill_plane: None,
|
|
fill_plane_boundary: None,
|
|
boundary_exterior: None,
|
|
boundary_sources: None,
|
|
boundary_paths: None,
|
|
style: codec::entities::HatchStyleType::Normal,
|
|
pattern: self.pattern.clone(),
|
|
name: String::new(),
|
|
color: [1.0; 4],
|
|
aci: 0,
|
|
line_weight_px: 1.0,
|
|
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 / active-field key identifying which value a row edits. Geometry
|
|
/// and common fields key on their `&'static str` field name; block attribute
|
|
/// rows key on the attribute's tag. One typed enum replaces the old
|
|
/// `\x01attr\x01…` sentinel prefix, so the two key namespaces can't collide by
|
|
/// construction.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
pub enum FieldKey {
|
|
/// A geometry / common editable value field, keyed by its field name.
|
|
Geom(&'static str),
|
|
/// A block attribute value field, keyed by its tag.
|
|
Attr(String),
|
|
/// One column of one named-parameter row, keyed by its stable
|
|
/// `ParameterTable::iter()` position (see `PropValue::ParamRow`'s doc
|
|
/// comment for why index rather than name).
|
|
Param(usize, crate::ui::window::named_parameters::ParamField),
|
|
}
|
|
|
|
impl FieldKey {
|
|
/// The text-input widget id backing this key (the shape used by the row
|
|
/// renderer and the `PropSyncActive` handler). Geometry keys map straight
|
|
/// onto their field id; attribute keys map onto the tag's field id.
|
|
pub fn widget_id(&self) -> iced::widget::Id {
|
|
match self {
|
|
FieldKey::Geom(field) => prop_geom_field_id(field),
|
|
FieldKey::Attr(tag) => prop_attr_field_id(tag),
|
|
FieldKey::Param(index, field) => prop_param_field_id(*index, *field),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Text-input widget id for one column of a named-parameter row.
|
|
pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField) -> iced::widget::Id {
|
|
use crate::ui::window::named_parameters::ParamField;
|
|
let suffix = match field {
|
|
ParamField::Name => "name",
|
|
ParamField::Formula => "formula",
|
|
};
|
|
iced::widget::Id::from(format!("props-param-field-{index}-{suffix}"))
|
|
}
|
|
|
|
/// Edit-buffer / active-field key for a block attribute value, keyed by its
|
|
/// tag. Kept in one place so the live-input handler and the row renderer agree.
|
|
pub fn attr_edit_key(tag: &str) -> FieldKey {
|
|
FieldKey::Attr(tag.to_string())
|
|
}
|
|
|
|
/// Text-input widget id for a geometry/common editable value field. The same
|
|
/// id (built from the `&'static str` key) is used by the row renderer to tag
|
|
/// the input and by the update handler to focus / select it on click.
|
|
pub fn prop_geom_field_id(field: &str) -> iced::widget::Id {
|
|
iced::widget::Id::from(format!("props-geom-field-{field}"))
|
|
}
|
|
|
|
/// Text-input widget id for a block attribute value field, keyed by the tag.
|
|
pub fn prop_attr_field_id(tag: &str) -> iced::widget::Id {
|
|
iced::widget::Id::from(format!("props-attr-field-{tag}"))
|
|
}
|
|
|
|
/// Keeps the active-row highlight keyed to real keyboard focus: returns true
|
|
/// only while the currently-focused widget IS the text input of the active
|
|
/// field. Focus on any other widget (another property field, a non-property
|
|
/// widget, or nothing at all) clears the marker. Setting the key for a newly
|
|
/// focused property field is handled by the `PropSyncActive` handler before
|
|
/// this runs.
|
|
pub fn active_key_focused(
|
|
active_key: Option<&FieldKey>,
|
|
focused: Option<&iced::widget::Id>,
|
|
) -> bool {
|
|
match (active_key, focused) {
|
|
(Some(key), Some(focused_id)) => focused_id == &key.widget_id(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// 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, 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> {
|
|
let mut map = HashMap::default();
|
|
for section in sections {
|
|
for prop in §ion.props {
|
|
let key = match &prop.value {
|
|
PropValue::EditText(_)
|
|
| PropValue::PlainText(_)
|
|
| PropValue::EditChoice { .. } => {
|
|
Some(FieldKey::Geom(prop.field))
|
|
}
|
|
PropValue::AttrText { tag, .. } => Some(FieldKey::Attr(tag.clone())),
|
|
_ => None,
|
|
};
|
|
if let Some(key) = key {
|
|
map.insert(key.widget_id(), key);
|
|
}
|
|
if let PropValue::ParamRow { index, .. } = &prop.value {
|
|
use crate::ui::window::named_parameters::ParamField;
|
|
for field in [ParamField::Name, ParamField::Formula] {
|
|
let key = FieldKey::Param(*index, field);
|
|
map.insert(key.widget_id(), key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
map
|
|
}
|
|
|
|
/// Returns a task that sweeps the widget tree for the currently-focused widget
|
|
/// and reports its `Id` via [`Message::PropSyncActive`], which the update
|
|
/// handler uses to reconcile the active-row marker against real focus. Mirrors
|
|
/// the `WebFieldCopy` operation idiom: `focusable` fires for every focusable
|
|
/// widget, and we record the one that `is_focused()`.
|
|
pub fn sync_active_field_task() -> iced::Task<Message> {
|
|
use iced::advanced::widget::operation::{Focusable, Outcome};
|
|
use iced::advanced::widget::{Id, Operation};
|
|
#[derive(Default)]
|
|
struct FocusedId {
|
|
focused: Option<Id>,
|
|
}
|
|
impl Operation<Option<Id>> for FocusedId {
|
|
fn focusable(
|
|
&mut self,
|
|
id: Option<&Id>,
|
|
_bounds: iced::Rectangle,
|
|
state: &mut dyn Focusable,
|
|
) {
|
|
if state.is_focused() && self.focused.is_none() {
|
|
self.focused = id.cloned();
|
|
}
|
|
}
|
|
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<Option<Id>>)) {
|
|
operate(self);
|
|
}
|
|
fn finish(&self) -> Outcome<Option<Id>> {
|
|
Outcome::Some(self.focused.clone())
|
|
}
|
|
}
|
|
iced::advanced::widget::operate(FocusedId::default()).map(Message::PropSyncActive)
|
|
}
|
|
|
|
/// A translated choice label paired with the unchanged value stored in the
|
|
/// drawing and emitted by the properties panel.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct LocalizedChoice {
|
|
pub raw: String,
|
|
label: String,
|
|
}
|
|
|
|
impl LocalizedChoice {
|
|
pub fn new(raw: String) -> Self {
|
|
let label = crate::i18n::translate(&raw).into_owned();
|
|
Self { raw, label }
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for LocalizedChoice {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(&self.label)
|
|
}
|
|
}
|
|
|
|
// ── 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<LocalizedChoice>>,
|
|
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 [`FieldKey`].
|
|
pub edit_buf: HashMap<FieldKey, 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 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,
|
|
/// Draw the Current Vertex indicator only after the user changes the
|
|
/// stepper for the current selection.
|
|
pub prop_vertex_indicator_active: bool,
|
|
/// 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>,
|
|
/// Property sections the user collapsed. This is a rendered copy of the
|
|
/// app-wide preference, allowing every open document panel to share it.
|
|
/// Empty by default, meaning every section is initially shown.
|
|
pub collapsed_sections: HashSet<String>,
|
|
/// Whether the editable-dropdown (block Name) option list is open.
|
|
pub edit_choice_open: bool,
|
|
/// Field key of the value row currently being edited, or `None`. Marked when
|
|
/// the user focuses an editable value field and cleared on commit / when the
|
|
/// selection changes, so the active row can be highlighted. Geometry fields
|
|
/// use [`FieldKey::Geom`]; attribute rows use [`FieldKey::Attr`].
|
|
pub active_field: Option<FieldKey>,
|
|
/// Focused-text-input id → [`FieldKey`] for the editable value rows in
|
|
/// [`PropertiesPanel::sections`], precomputed by [`build_field_key_map`]
|
|
/// when the panel is rebuilt. Gives the `PropSyncActive` handler an O(1)
|
|
/// id→key lookup. Derived from `sections`: rebuild it alongside them.
|
|
pub field_key_by_id: HashMap<iced::widget::Id, FieldKey>,
|
|
}
|
|
|
|
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,
|
|
bg_color_picker_open: false,
|
|
open_color_field: None,
|
|
prop_vertex: 0,
|
|
prop_vertex_indicator_active: false,
|
|
expanded_groups: HashSet::default(),
|
|
collapsed_sections: HashSet::default(),
|
|
edit_choice_open: false,
|
|
active_field: None,
|
|
field_key_by_id: HashMap::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PropertiesPanel {
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
title: t!("No selection").into_owned(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Maps a focused text-input widget id back to the [`FieldKey`] of that
|
|
/// property row, if the id belongs to an editable value field currently
|
|
/// shown. O(1) via the [`PropertiesPanel::field_key_by_id`] map.
|
|
pub fn prop_field_key_for_id(&self, id: &iced::widget::Id) -> Option<FieldKey> {
|
|
self.field_key_by_id.get(id).cloned()
|
|
}
|
|
|
|
pub fn selected_handles(&self) -> Vec<Handle> {
|
|
self.selected_group
|
|
.as_ref()
|
|
.map(|group| group.handles.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> {
|
|
use crate::ui::dock::PanelId;
|
|
// ── Header ──────────────────────────────────────────────────────────
|
|
let header =
|
|
crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), auto_collapse);
|
|
|
|
// ── 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(t!("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.width(Length::Fill))
|
|
.spacing(8)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.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(Length::Fixed(width))
|
|
.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.source_handles.is_empty() || 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 sections = column![].spacing(0);
|
|
for section in &self.sections {
|
|
sections = sections.push(self.render_section(section));
|
|
}
|
|
|
|
let content = scrollable(sections.width(Length::Fill))
|
|
.spacing(8)
|
|
.width(Length::Fill)
|
|
.height(Length::Shrink);
|
|
|
|
Some(
|
|
container(column![title, content].spacing(0))
|
|
.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> {
|
|
let collapsed = self.collapsed_sections.contains(§ion.title);
|
|
let toggle = button(if collapsed {
|
|
crate::ui::icons::themed_arrow_right(10.0)
|
|
} else {
|
|
crate::ui::icons::themed_arrow_down(10.0)
|
|
})
|
|
.on_press(Message::PropSectionToggle(section.title.clone()))
|
|
.style(button::text)
|
|
.padding([0, 3]);
|
|
|
|
// The arrow intentionally sits at the far end of the header. The rest
|
|
// of the header responds to a double-click, leaving a single click on
|
|
// the arrow as the precise, discoverable toggle target.
|
|
let title = mouse_area(
|
|
container(text(§ion.title).size(10))
|
|
.width(Length::Fill)
|
|
.padding([3, 8]),
|
|
)
|
|
.on_double_click(Message::PropSectionToggle(section.title.clone()));
|
|
let hdr = container(
|
|
row![
|
|
title,
|
|
toggle,
|
|
]
|
|
.align_y(iced::Center),
|
|
)
|
|
.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);
|
|
|
|
let mut col = column![hdr].spacing(0);
|
|
if collapsed {
|
|
return col.into();
|
|
}
|
|
|
|
// 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, None)
|
|
}
|
|
PropValue::NamedColorChoice { color, name } => {
|
|
self.render_color_row(label, prop.field, *color, Some(name))
|
|
}
|
|
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::FieldLwChoice { field, value } => {
|
|
self.render_field_lw_row(label, field, *value)
|
|
}
|
|
PropValue::FieldLwVaries { field } => {
|
|
self.render_field_lw_varies_row(label, field)
|
|
}
|
|
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) | PropValue::PlainText(val) => {
|
|
self.render_edit_row(label, prop.field, val)
|
|
}
|
|
PropValue::Hyperlink(val) => render_hyperlink_row(label, val),
|
|
PropValue::ReadOnly(val) if prop.field == "annotative_scale" => {
|
|
render_annotative_scale_row(label, val)
|
|
}
|
|
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)
|
|
}
|
|
PropValue::AttrText { tag, value } => self.render_attr_row(tag, value),
|
|
PropValue::EntityLink { id, handles, conflicting } => {
|
|
render_entity_link_row(label, *id, handles.clone(), *conflicting)
|
|
}
|
|
PropValue::ParamRow { index, name, formula, resolved } => {
|
|
self.render_param_row(*index, name, formula, resolved)
|
|
}
|
|
PropValue::ParamAddRow => render_param_add_row(),
|
|
PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*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,
|
|
display_name: Option<&'a str>,
|
|
) -> 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,
|
|
..Default::default()
|
|
},
|
|
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,
|
|
color,
|
|
),
|
|
);
|
|
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 matches!(
|
|
field,
|
|
"gradient_color_1"
|
|
| "gradient_color_2"
|
|
| "dim_line_color"
|
|
| "dim_ext_line_color"
|
|
| "dim_text_color"
|
|
| "dim_text_fill_color"
|
|
| "line_color"
|
|
| "text_color"
|
|
| "block_content_color"
|
|
| "background_fill_color"
|
|
| "indicator_fill_color"
|
|
) {
|
|
let open = self.open_color_field.as_deref() == Some(field);
|
|
let fsel = field.to_string();
|
|
let full_palette_field = field.to_string();
|
|
let extras = if field == "dim_text_fill_color" {
|
|
crate::ui::color_select::ColorExtras {
|
|
none: true,
|
|
background: true,
|
|
..Default::default()
|
|
}
|
|
} else if field.starts_with("dim_")
|
|
|| matches!(field, "line_color" | "text_color" | "block_content_color")
|
|
{
|
|
crate::ui::color_select::ColorExtras {
|
|
by_layer: true,
|
|
by_block: true,
|
|
..Default::default()
|
|
}
|
|
} else {
|
|
crate::ui::color_select::ColorExtras {
|
|
by_layer: false,
|
|
by_block: false,
|
|
..Default::default()
|
|
}
|
|
};
|
|
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::OpenColorWindow(
|
|
crate::app::ColorPickTarget::PropertiesField(full_palette_field),
|
|
color,
|
|
),
|
|
);
|
|
return prop_row_widget(label, selector);
|
|
}
|
|
let selector = crate::ui::color_select::color_selector_with_name(
|
|
color,
|
|
display_name,
|
|
self.color_picker_open,
|
|
crate::ui::color_select::ColorExtras {
|
|
by_layer: true,
|
|
by_block: true,
|
|
..Default::default()
|
|
},
|
|
Message::PropColorChanged,
|
|
Message::PropColorPickerToggle,
|
|
Message::OpenColorWindow(
|
|
crate::app::ColorPickTarget::Properties,
|
|
color,
|
|
),
|
|
);
|
|
prop_row_widget(label, selector)
|
|
}
|
|
|
|
fn render_color_varies_row<'a>(&'a self, label: &'a str) -> Element<'a, Message> {
|
|
let selector = crate::ui::color_select::color_selector_varies(
|
|
self.color_picker_open,
|
|
crate::ui::color_select::ColorExtras {
|
|
by_layer: true,
|
|
by_block: true,
|
|
..Default::default()
|
|
},
|
|
Message::PropColorChanged,
|
|
Message::PropColorPickerToggle,
|
|
Message::OpenColorWindow(
|
|
crate::app::ColorPickTarget::Properties,
|
|
AcadColor::ByLayer,
|
|
),
|
|
);
|
|
prop_row_widget(label, selector)
|
|
}
|
|
|
|
// ── 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_field_lw_row<'a>(
|
|
&'a self,
|
|
label: &'a str,
|
|
field: &'static str,
|
|
lw: LineWeight,
|
|
) -> Element<'a, Message> {
|
|
let selected = LwItem(lw);
|
|
let combo = combo_box(
|
|
&self.lineweight_combo,
|
|
"",
|
|
Some(&selected),
|
|
move |item: LwItem| Message::PropFieldLwChanged {
|
|
field,
|
|
value: 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())
|
|
}
|
|
|
|
fn render_field_lw_varies_row<'a>(
|
|
&'a self,
|
|
label: &'a str,
|
|
field: &'static str,
|
|
) -> Element<'a, Message> {
|
|
let combo = combo_box(
|
|
&self.lineweight_combo,
|
|
VARIES_LABEL,
|
|
None,
|
|
move |item: LwItem| Message::PropFieldLwChanged {
|
|
field,
|
|
value: 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,
|
|
crate::ui::wide_menu::wide_menu(combo, LINETYPE_MENU_W),
|
|
)
|
|
}
|
|
|
|
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(LocalizedChoice::new(current.to_string()))
|
|
};
|
|
let combo = combo_box(state, VARIES_LABEL, selected.as_ref(), move |choice| {
|
|
Message::PropGeomChoiceChanged {
|
|
field,
|
|
value: choice.raw,
|
|
}
|
|
})
|
|
.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 key = FieldKey::Geom(field);
|
|
let display = self
|
|
.edit_buf
|
|
.get(&key)
|
|
.map(|s| s.as_str())
|
|
.unwrap_or(entity_val);
|
|
|
|
let active = self.active_field.as_ref() == Some(&key);
|
|
let ti = 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(text_input_style)
|
|
.padding([3, 6])
|
|
.width(Length::Fill);
|
|
|
|
prop_row_with_active(label, ti.into(), active)
|
|
}
|
|
|
|
/// 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 dropdown list of the definitions 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 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);
|
|
|
|
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 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,
|
|
radius: 0.0.into(),
|
|
},
|
|
..text_input_style(theme, status)
|
|
})
|
|
.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(9.0)
|
|
} else {
|
|
crate::ui::icons::themed_arrow_down(9.0)
|
|
})
|
|
.align_y(iced::Center),
|
|
)
|
|
.on_press(Message::PropEditChoiceToggle)
|
|
.style(|theme: &Theme, status| {
|
|
let palette = theme.palette();
|
|
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: bg,
|
|
text_color,
|
|
border: Border::default(),
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding(Padding {
|
|
top: COMBO_PAD_V,
|
|
bottom: COMBO_PAD_V,
|
|
left: 3.0,
|
|
right: 4.0,
|
|
});
|
|
let head = container(row![input, caret].align_y(iced::Center))
|
|
.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: border_color,
|
|
width: 1.0,
|
|
radius: 2.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.width(Length::Fill);
|
|
|
|
if !self.edit_choice_open {
|
|
return prop_row_with_active(label, head.into(), active);
|
|
}
|
|
|
|
// 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);
|
|
|
|
prop_row_with_active(
|
|
label,
|
|
crate::ui::color_select::drop_down_below(
|
|
head.into(),
|
|
popup.into(),
|
|
None,
|
|
Length::Shrink,
|
|
Message::PropEditChoiceToggle,
|
|
),
|
|
active,
|
|
)
|
|
}
|
|
|
|
/// 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 active = self.active_field.as_ref() == Some(&key);
|
|
let tag_for_input = tag.to_string();
|
|
let ti = text_input("", display)
|
|
.id(prop_attr_field_id(tag))
|
|
.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_with_active(tag, ti.into(), active)
|
|
}
|
|
|
|
/// One named-parameter row: editable name + formula, with the live-
|
|
/// resolved value (or error) shown alongside, embedded in the panel instead of
|
|
/// the old separate modal. Each field commits on submit (Enter / losing
|
|
/// focus), not per keystroke — `ParameterTable::set` validates
|
|
/// immediately and would otherwise reject a formula mid-type (the same
|
|
/// reasoning the old modal's buffered-Apply design used, just applied
|
|
/// per-field instead of per-whole-table).
|
|
fn render_param_row<'a>(
|
|
&'a self,
|
|
index: usize,
|
|
name: &'a str,
|
|
formula: &'a str,
|
|
resolved: &'a Result<f64, String>,
|
|
) -> Element<'a, Message> {
|
|
use crate::ui::window::named_parameters::ParamField;
|
|
|
|
let name_key = FieldKey::Param(index, ParamField::Name);
|
|
let name_display = self.edit_buf.get(&name_key).map(|s| s.as_str()).unwrap_or(name);
|
|
let name_active = self.active_field.as_ref() == Some(&name_key);
|
|
let name_input = text_input("name", name_display)
|
|
.id(name_key.widget_id())
|
|
.on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v })
|
|
.on_submit(Message::PropParamCommit { index, field: ParamField::Name })
|
|
.size(FONT_SZ)
|
|
.style(text_input_style)
|
|
.padding([3, 6])
|
|
.width(Length::FillPortion(3));
|
|
|
|
let formula_key = FieldKey::Param(index, ParamField::Formula);
|
|
let formula_display = self.edit_buf.get(&formula_key).map(|s| s.as_str()).unwrap_or(formula);
|
|
let formula_active = self.active_field.as_ref() == Some(&formula_key);
|
|
let formula_input = text_input("formula", formula_display)
|
|
.id(formula_key.widget_id())
|
|
.on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v })
|
|
.on_submit(Message::PropParamCommit { index, field: ParamField::Formula })
|
|
.size(FONT_SZ)
|
|
.style(text_input_style)
|
|
.padding([3, 6])
|
|
.width(Length::FillPortion(3));
|
|
|
|
let (value_text, is_error) = match resolved {
|
|
Ok(v) => (format!("{v:.4}"), false),
|
|
Err(_) => ("—".to_string(), true),
|
|
};
|
|
let value_label = container(
|
|
text(value_text).size(FONT_SZ).style(move |theme: &Theme| iced::widget::text::Style {
|
|
color: is_error.then_some(theme.palette().danger.base.color),
|
|
}),
|
|
)
|
|
.width(Length::FillPortion(2))
|
|
.align_x(iced::Right);
|
|
|
|
let delete_btn = button(text("\u{2715}").size(FONT_SZ))
|
|
.on_press(Message::PropParamDelete(index))
|
|
.style(button::text)
|
|
.padding([2, 6]);
|
|
|
|
let active = name_active || formula_active;
|
|
let bg = move |theme: &Theme| {
|
|
if active {
|
|
Background::Color(theme.palette().primary.weak.color)
|
|
} else {
|
|
Background::Color(theme.palette().background.base.color)
|
|
}
|
|
};
|
|
let content = container(row![name_input, formula_input, value_label, delete_btn].spacing(4).align_y(iced::Center))
|
|
.style(move |theme: &Theme| container::Style { background: Some(bg(theme)), ..Default::default() })
|
|
.padding([2, 6])
|
|
.width(Length::Fill);
|
|
|
|
if let Err(err) = resolved {
|
|
tooltip(content, text(err.as_str()).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.danger.weak.color)),
|
|
text_color: Some(palette.danger.weak.text),
|
|
border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into() },
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
} else {
|
|
content.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(t!("Search patterns…").as_ref(), &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(t!("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::Fill)
|
|
.width(Length::Fill)
|
|
.into()
|
|
};
|
|
let popup = container(column![search, results].spacing(7))
|
|
.style(container::bordered_box)
|
|
.padding(8)
|
|
.width(PATTERN_PICKER_W)
|
|
.height(Length::Fixed(PATTERN_PICKER_H));
|
|
|
|
prop_row_widget(
|
|
label,
|
|
crate::ui::color_select::drop_down_below(
|
|
head.into(),
|
|
popup.into(),
|
|
Some(Length::Fixed(PATTERN_PICKER_W)),
|
|
Length::Fixed(PATTERN_PICKER_H),
|
|
Message::PropHatchPatternPickerToggle(current.to_string()),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
t!("Yes").into_owned()
|
|
} else {
|
|
t!("No").into_owned()
|
|
};
|
|
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 {
|
|
if matches!(props[idx].field, "pl3_vertex_x" | "pm_vx") {
|
|
return 0;
|
|
}
|
|
let groupable = |p: &crate::scene::model::object::Property| {
|
|
matches!(
|
|
p.value,
|
|
PropValue::EditText(_)
|
|
| PropValue::ReadOnly(_)
|
|
| PropValue::ReadOnlyWithTooltip { .. }
|
|
)
|
|
};
|
|
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)
|
|
| PropValue::ReadOnlyWithTooltip { value: 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);
|
|
|
|
// The field copies the value, so the locally-built `joined` is fine here.
|
|
let value_field = crate::ui::read_only::field(&joined, FONT_SZ, 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_annotative_scale_row<'a>(
|
|
label: &'a str,
|
|
value: &'a str,
|
|
) -> Element<'a, Message> {
|
|
let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill);
|
|
|
|
let manage = button(text("...").size(FONT_SZ))
|
|
.on_press(Message::AnnoObjectScaleOpen)
|
|
.style(button::secondary)
|
|
.padding([2, 7]);
|
|
|
|
let controls = row![
|
|
field,
|
|
manage,
|
|
iced::widget::space().width(10)
|
|
]
|
|
.spacing(2)
|
|
.align_y(iced::Center)
|
|
.width(Length::Fill);
|
|
|
|
prop_row_widget(label, controls.into())
|
|
}
|
|
fn render_hyperlink_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
|
|
let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill);
|
|
let manage = button(text("...").size(FONT_SZ))
|
|
.on_press(Message::PropHyperlinkOpen)
|
|
.style(button::secondary)
|
|
.padding([2, 7]);
|
|
let controls = row![field, manage, iced::widget::space().width(10)]
|
|
.spacing(2)
|
|
.align_y(iced::Center)
|
|
.width(Length::Fill);
|
|
prop_row_widget(label, controls.into())
|
|
}
|
|
fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
|
|
// A read-only value is shown as a non-editable but selectable field: no
|
|
// on_input means the caret never appears, but the text can be selected
|
|
// (carrying the full, un-truncated value) and copied with Ctrl+C.
|
|
let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill);
|
|
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())
|
|
}
|
|
|
|
// ── Constraints section row (clickable entity link) ───────────────────────
|
|
|
|
/// One persistent-constraint row in the Constraints properties section:
|
|
/// `label` is the glyph + kind name + resolved value (e.g. "↔ Distance:
|
|
/// hole_dia = 12.00"), rendered as the row's own clickable surface — there's
|
|
/// no natural separate "value" for a constraint row, unlike a Layer/Color
|
|
/// field, so this doesn't use the usual `prop_row_widget` label|value split.
|
|
/// Clicking it selects every entity in `handles`; a conflicting/redundant
|
|
/// constraint (mirrors the viewport glyph pill's own color cue) tints red.
|
|
fn render_entity_link_row<'a>(
|
|
label: &'a str,
|
|
id: crate::scene::parametric_constraints::ConstraintId,
|
|
handles: Vec<Handle>,
|
|
conflicting: bool,
|
|
) -> Element<'a, Message> {
|
|
let link = button(text(label).size(FONT_SZ).width(Length::Fill))
|
|
.on_press(Message::PropConstraintLinkClick(handles))
|
|
.style(move |theme: &Theme, status| {
|
|
let palette = theme.palette();
|
|
let pair = if conflicting {
|
|
palette.danger.weak
|
|
} else {
|
|
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()
|
|
}
|
|
})
|
|
.padding([3, 8])
|
|
.width(Length::Fill);
|
|
let delete = button(text("\u{2715}").size(FONT_SZ))
|
|
.on_press(Message::PropConstraintDelete(id))
|
|
.style(button::text)
|
|
.padding([2, 6]);
|
|
container(row![link, delete].spacing(2).align_y(iced::Center))
|
|
.width(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
// ── Parameters section: "+ Add parameter" row ──────────────────────────────
|
|
|
|
/// The trailing "+ Add parameter" row: appends a fresh, uniquely-named
|
|
/// parameter (`param1`, `param2`, …) the user then renames/redefines inline
|
|
/// — avoids a separate "pending new row" concept, since every row always
|
|
/// reflects a real committed table entry.
|
|
fn render_param_add_row<'a>() -> Element<'a, Message> {
|
|
let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center))
|
|
.on_press(Message::PropParamAddNew)
|
|
.style(button::text)
|
|
.padding([4, 8])
|
|
.width(Length::Fill);
|
|
container(btn).width(Length::Fill).into()
|
|
}
|
|
|
|
// ── Parameters section: leading global visibility toggle ───────────────────
|
|
|
|
/// The Parameters section's leading header row (no-selection page): a
|
|
/// global on/off toggle for whether any constraint pill in the viewport
|
|
/// shows its driven value/parameter-name text — lives next to the
|
|
/// named-parameter table it governs.
|
|
fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> {
|
|
let btn_label = if value { t!("On") } else { t!("Off") }.into_owned();
|
|
let btn = button(
|
|
row![
|
|
crate::ui::icons::semantic(crate::ui::icons::layer_visible(value), 13.0),
|
|
text(btn_label).size(FONT_SZ),
|
|
]
|
|
.spacing(6)
|
|
.align_y(iced::Center),
|
|
)
|
|
.on_press(Message::ShowConstraintValuesChanged(!value))
|
|
.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: pair.text,
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding([4, 8])
|
|
.width(Length::Fill);
|
|
container(row![text(t!("Values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center))
|
|
.width(Length::Fill)
|
|
.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)
|
|
}
|
|
|
|
/// Like [`prop_row_widget`] but tints the row as the currently-active edit
|
|
/// row when `active` is true, using the palette's `primary.weak` highlight so
|
|
/// it reads as "selected" without shouting.
|
|
fn prop_row_with_active<'a>(
|
|
label: &'a str,
|
|
widget: Element<'a, Message>,
|
|
active: bool,
|
|
) -> Element<'a, Message> {
|
|
let label_col = container(
|
|
text(crate::ui::text_util::elide(label, 18))
|
|
.size(FONT_SZ)
|
|
.style(muted_text_style),
|
|
)
|
|
.style(move |theme: &Theme| container::Style {
|
|
background: Some(Background::Color(if active {
|
|
theme.palette().primary.weak.color
|
|
} else {
|
|
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(move |theme: &Theme| container::Style {
|
|
background: Some(Background::Color(if active {
|
|
theme.palette().primary.weak.color
|
|
} else {
|
|
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(move |theme: &Theme| container::Style {
|
|
border: Border {
|
|
color: if active {
|
|
theme.palette().primary.base.color
|
|
} else {
|
|
theme.palette().background.neutral.color
|
|
},
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
})
|
|
.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) = codec::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 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)
|
|
}
|
|
|
|
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::{
|
|
active_key_focused, attr_edit_key, build_field_key_map, hatch_pattern_matches,
|
|
hatch_preview_scale, prop_attr_field_id, prop_geom_field_id, FieldKey,
|
|
};
|
|
|
|
fn sample_sections() -> Vec<crate::scene::model::object::PropSection> {
|
|
use crate::scene::model::object::{Property, PropValue};
|
|
vec![
|
|
crate::scene::model::object::PropSection {
|
|
title: "Geometry".into(),
|
|
props: vec![Property {
|
|
label: "X".into(),
|
|
field: "pos_x",
|
|
value: PropValue::EditText("1.0".into()),
|
|
}],
|
|
},
|
|
crate::scene::model::object::PropSection {
|
|
title: "Attributes".into(),
|
|
props: vec![Property {
|
|
label: "TITLE".into(),
|
|
field: "0",
|
|
value: PropValue::AttrText {
|
|
tag: "TITLE".into(),
|
|
value: "hello".into(),
|
|
},
|
|
}],
|
|
},
|
|
crate::scene::model::object::PropSection {
|
|
title: "Reference".into(),
|
|
props: vec![Property {
|
|
label: "Name".into(),
|
|
field: "name",
|
|
value: PropValue::EditChoice {
|
|
value: "Door".into(),
|
|
options: vec!["Door".into(), "Window".into()],
|
|
},
|
|
}],
|
|
},
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn focus_transition_updates_the_active_field_highlight() {
|
|
// The highlight is keyed to real keyboard focus: it persists only while
|
|
// the focused widget is THAT field's text input, and clears when focus
|
|
// moves to any other widget (or nowhere).
|
|
//
|
|
// Focus on the active field's own input → row stays marked active.
|
|
let focused = prop_geom_field_id("pos_x");
|
|
assert!(active_key_focused(Some(&FieldKey::Geom("pos_x")), Some(&focused)));
|
|
|
|
// Focus moves to another property field → superseded (cleared here; the
|
|
// manual focus-setter then marks the new field active).
|
|
assert!(!active_key_focused(
|
|
Some(&FieldKey::Geom("pos_x")),
|
|
Some(&prop_geom_field_id("pos_y"))
|
|
));
|
|
|
|
// Focus moves to a non-property widget / nothing → cleared.
|
|
assert!(!active_key_focused(
|
|
Some(&FieldKey::Geom("pos_x")),
|
|
Some(&prop_attr_field_id("TITLE"))
|
|
));
|
|
assert!(!active_key_focused(Some(&FieldKey::Geom("pos_x")), None));
|
|
|
|
// No active field (or one whose input isn't focused) never reads active.
|
|
assert!(!active_key_focused(None, Some(&prop_geom_field_id("pos_x"))));
|
|
}
|
|
|
|
#[test]
|
|
fn field_ids_are_distinct_across_geometries_and_attributes() {
|
|
assert_ne!(prop_geom_field_id("pos_x"), prop_geom_field_id("pos_y"));
|
|
assert_ne!(prop_geom_field_id("pos_x"), prop_attr_field_id("TITLE"));
|
|
assert_eq!(prop_attr_field_id("TITLE"), prop_attr_field_id("TITLE"));
|
|
}
|
|
|
|
#[test]
|
|
fn prop_field_key_for_id_maps_focus_ids_to_active_keys() {
|
|
let sections = sample_sections();
|
|
let map = build_field_key_map(§ions);
|
|
|
|
// A focused geometry edit field maps back to its `&'static str` key,
|
|
// and round-trips through `active_key_focused`.
|
|
let geom_id = prop_geom_field_id("pos_x");
|
|
assert_eq!(map.get(&geom_id), Some(&FieldKey::Geom("pos_x")));
|
|
assert!(active_key_focused(map.get(&geom_id), Some(&geom_id)));
|
|
|
|
// A focused attribute edit field maps back to its tag key.
|
|
let attr_id = prop_attr_field_id("TITLE");
|
|
assert_eq!(map.get(&attr_id), Some(&attr_edit_key("TITLE")));
|
|
assert!(active_key_focused(map.get(&attr_id), Some(&attr_id)));
|
|
|
|
// 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);
|
|
assert_eq!(map.get(&iced::widget::Id::new("elsewhere")), None);
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|