forked from 1b-agent/opencadstudio-upstream
feat(plugin): harden V4 host integration
Preserve legacy wire indices and route concurrent V4 responses and background requests by correlation and stable tab ID.
This commit is contained in:
commit
f247d15f6e
110 files changed
+32159
-1254
No files matched your search
+32
-10
@@ -157,7 +157,7 @@ impl DockState {
|
||||
pub fn width(&self, id: PanelId, win_w: f32) -> f32 {
|
||||
self.settings(id)
|
||||
.width
|
||||
.clamp(DOCK_MIN_W, DOCK_MAX_W.min(win_w * 0.45))
|
||||
.clamp(DOCK_MIN_W, DOCK_MAX_W.min(win_w * 0.45).max(DOCK_MIN_W))
|
||||
}
|
||||
|
||||
pub fn auto_collapse(&self, id: PanelId) -> bool {
|
||||
@@ -195,11 +195,6 @@ impl DockState {
|
||||
stack.insert(index, id);
|
||||
true
|
||||
}
|
||||
|
||||
/// Number of panels currently docked on `side`.
|
||||
pub fn len(&self, side: DockSide) -> usize {
|
||||
self.stack(side).len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Insertion index (0..=total) for dropping a panel whose pointer is at
|
||||
@@ -211,6 +206,11 @@ pub fn drop_index(y: f32, total: usize, avail: f32) -> usize {
|
||||
}
|
||||
let h = avail / total as f32;
|
||||
let idx = (y / h).round() as usize;
|
||||
// A single full-height panel must not offer a top/bottom split: any drop
|
||||
// lands in the one available slot.
|
||||
if total <= 1 {
|
||||
return 0;
|
||||
}
|
||||
idx.min(total)
|
||||
}
|
||||
|
||||
@@ -288,6 +288,17 @@ mod tests {
|
||||
assert_eq!(state.width(PanelId::BlockPalette, 800.0), DOCK_MAX_W.min(360.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_does_not_panic_when_window_minimized() {
|
||||
let mut state = DockState::default();
|
||||
state.set_width(PanelId::BlockPalette, 500.0);
|
||||
// A minimized or not-yet-laid-out window reports width 0; the clamp
|
||||
// must fall back to DOCK_MIN_W instead of panicking on min > max.
|
||||
assert_eq!(state.width(PanelId::BlockPalette, 0.0), DOCK_MIN_W);
|
||||
// Deleted right below the minimum dock width behaves the same way.
|
||||
assert_eq!(state.width(PanelId::BlockPalette, 100.0), DOCK_MIN_W);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_maps_between_zero_and_total() {
|
||||
assert_eq!(drop_index(0.0, 3, 300.0), 0);
|
||||
@@ -298,9 +309,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn len_reports_stack_size() {
|
||||
let state = DockState::default();
|
||||
assert_eq!(state.len(DockSide::Left), 1);
|
||||
assert_eq!(state.len(DockSide::Right), 1);
|
||||
fn drop_index_single_slot_is_always_zero() {
|
||||
// One panel sharing the full edge height: any pointer y lands in the
|
||||
// single slot, so the insertion index must be 0 (no top/bottom split).
|
||||
assert_eq!(drop_index(5.0, 1, 900.0), 0);
|
||||
assert_eq!(drop_index(450.0, 1, 900.0), 0);
|
||||
assert_eq!(drop_index(895.0, 1, 900.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_multi_slot_maps_position() {
|
||||
// Two panels on an edge produce insertion positions 0..=total: pointer
|
||||
// near the top lands before the first slot (0); pointer near the bottom
|
||||
// of the last slot lands after the last (== total, append).
|
||||
assert_eq!(drop_index(10.0, 2, 900.0), 0);
|
||||
assert_eq!(drop_index(890.0, 2, 900.0), 2);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod modal;
|
||||
pub mod overlay;
|
||||
pub mod popup;
|
||||
pub mod properties;
|
||||
pub mod read_only;
|
||||
pub mod ribbon;
|
||||
pub mod side_toolbar;
|
||||
pub mod statusbar;
|
||||
|
||||
+287
-65
@@ -130,6 +130,8 @@ impl canvas::Program<Message> for HatchPatternPreview {
|
||||
[pad, bounds.height - pad],
|
||||
]),
|
||||
boundary_wcs: None,
|
||||
boundary_exterior: None,
|
||||
boundary_sources: None,
|
||||
pattern: self.pattern.clone(),
|
||||
name: String::new(),
|
||||
color: [1.0; 4],
|
||||
@@ -240,11 +242,121 @@ pub fn lw_options() -> Vec<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}")
|
||||
/// 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),
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. The block-Name
|
||||
/// caret-dropdown (`EditChoice`) is editable but deliberately carries no id and
|
||||
/// is excluded here, mirroring the renderer.
|
||||
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(_) => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -289,8 +401,8 @@ pub struct PropertiesPanel {
|
||||
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>,
|
||||
/// 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
|
||||
@@ -322,6 +434,16 @@ pub struct PropertiesPanel {
|
||||
pub expanded_groups: 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 {
|
||||
@@ -350,6 +472,8 @@ impl Default for PropertiesPanel {
|
||||
prop_vertex_indicator_active: false,
|
||||
expanded_groups: HashSet::default(),
|
||||
edit_choice_open: false,
|
||||
active_field: None,
|
||||
field_key_by_id: HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +486,13 @@ impl PropertiesPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -931,13 +1062,16 @@ impl PropertiesPanel {
|
||||
field: &'static str,
|
||||
entity_val: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
let key = FieldKey::Geom(field);
|
||||
let display = self
|
||||
.edit_buf
|
||||
.get(field)
|
||||
.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)
|
||||
@@ -945,7 +1079,7 @@ impl PropertiesPanel {
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill);
|
||||
|
||||
prop_row_widget(label, ti.into())
|
||||
prop_row_with_active(label, ti.into(), active)
|
||||
}
|
||||
|
||||
/// Editable dropdown row (block reference Name): a text field with a caret
|
||||
@@ -960,9 +1094,13 @@ impl PropertiesPanel {
|
||||
entity_val: &'a str,
|
||||
options: &'a [String],
|
||||
) -> Element<'a, Message> {
|
||||
let typed = self.edit_buf.get(field);
|
||||
let typed = self.edit_buf.get(&FieldKey::Geom(field));
|
||||
let display = typed.map(|s| s.as_str()).unwrap_or(entity_val);
|
||||
|
||||
// NOTE: unlike `render_edit_row` / `render_attr_row`, this input is
|
||||
// deliberately NOT wrapped in a click-to-focus mouse_area. It is a
|
||||
// caret-dropdown: clicking should place the caret / open the list, and
|
||||
// the auto Select-all-on-focus would fight that. Don't "fix" it.
|
||||
let input = text_input("", display)
|
||||
.on_input(move |v| Message::PropGeomInput { field, value: v })
|
||||
.on_submit(Message::PropGeomCommit(field))
|
||||
@@ -1076,8 +1214,10 @@ impl PropertiesPanel {
|
||||
.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,
|
||||
@@ -1088,7 +1228,7 @@ impl PropertiesPanel {
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill);
|
||||
|
||||
prop_row_widget(tag, ti.into())
|
||||
prop_row_with_active(tag, ti.into(), active)
|
||||
}
|
||||
|
||||
fn render_hatch_pattern_row<'a>(
|
||||
@@ -1602,13 +1742,8 @@ fn render_group_row(
|
||||
.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);
|
||||
// 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(
|
||||
@@ -1642,12 +1777,7 @@ fn render_annotative_scale_row<'a>(
|
||||
label: &'a str,
|
||||
value: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
let field = text_input("", value)
|
||||
.on_input(|_| Message::Noop)
|
||||
.size(FONT_SZ)
|
||||
.style(ro_input_style)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill);
|
||||
let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill);
|
||||
|
||||
let manage = button(text("...").size(FONT_SZ))
|
||||
.on_press(Message::AnnoObjectScaleOpen)
|
||||
@@ -1666,30 +1796,37 @@ fn render_annotative_scale_row<'a>(
|
||||
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 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())
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 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(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.weakest.color,
|
||||
)),
|
||||
.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))
|
||||
@@ -1702,10 +1839,12 @@ fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<
|
||||
right: 6.0,
|
||||
});
|
||||
let value_col = container(widget)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.base.color,
|
||||
)),
|
||||
.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))
|
||||
@@ -1719,9 +1858,13 @@ fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<
|
||||
});
|
||||
container(row![label_col, value_col])
|
||||
.height(Length::Fixed(ROW_H))
|
||||
.style(|theme: &Theme| container::Style {
|
||||
.style(move |theme: &Theme| container::Style {
|
||||
border: Border {
|
||||
color: theme.palette().background.neutral.color,
|
||||
color: if active {
|
||||
theme.palette().primary.base.color
|
||||
} else {
|
||||
theme.palette().background.neutral.color
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
@@ -1836,25 +1979,6 @@ fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::S
|
||||
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)),
|
||||
@@ -1869,7 +1993,105 @@ fn hint_text_style(theme: &Theme) -> iced::widget::text::Style {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hatch_pattern_matches, hatch_preview_scale};
|
||||
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 block-Name caret-dropdown is editable but deliberately excluded.
|
||||
assert_eq!(map.get(&prop_geom_field_id("name")), None);
|
||||
|
||||
// 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() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use iced::widget::text_input::{self, Status};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
/// A read-only value field: a text input with no `on_input` handler.
|
||||
///
|
||||
/// In the pinned iced fork, an input without `on_input` is treated as
|
||||
/// disabled, so the caret is never drawn and typing does nothing — but mouse
|
||||
/// click/drag selection and Ctrl+C / Ctrl+A still work. This gives a field the
|
||||
/// user can select and copy but never edit or place a blinker in.
|
||||
///
|
||||
/// The value is copied into the widget's owned buffer, so the returned
|
||||
/// element does not borrow `value`: callers may hand in a transient `String`.
|
||||
pub fn field<'a, Message: Clone + 'a>(
|
||||
value: &str,
|
||||
size: f32,
|
||||
width: Length,
|
||||
) -> Element<'a, Message> {
|
||||
iced::widget::text_input("", value)
|
||||
.size(size)
|
||||
.style(read_only_style)
|
||||
.padding([3, 6])
|
||||
.width(width)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Muted "disabled input box" look: the same bordered box geometry as the
|
||||
/// editable fields but with a quieter background and text so it reads as
|
||||
/// read-only. The selection highlight is kept so copy stays discoverable.
|
||||
fn read_only_style(theme: &Theme, _status: Status) -> text_input::Style {
|
||||
let palette = theme.palette();
|
||||
text_input::Style {
|
||||
background: Background::Color(palette.background.weakest.color),
|
||||
border: Border {
|
||||
color: palette.background.neutral.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.scale_alpha(0.72),
|
||||
selection: palette.primary.base.color.scale_alpha(0.5),
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -318,13 +318,17 @@ impl Ribbon {
|
||||
self.show_layout_tabs = on;
|
||||
}
|
||||
/// Snapshot of every ribbon toggle's live state, for the render path.
|
||||
fn toggle_state(&self) -> widgets::ToggleState {
|
||||
/// The Block Palette highlight is threaded in from the app's authoritative
|
||||
/// `show_block_palette` rather than a ribbon copy, so it can't drift from
|
||||
/// the panel's true visibility.
|
||||
fn toggle_state(&self, show_block_palette: bool) -> widgets::ToggleState {
|
||||
use widgets::ToggleState;
|
||||
ToggleState {
|
||||
ortho_mode: self.ortho_mode,
|
||||
show_viewcube: self.show_viewcube,
|
||||
show_ucs_icon: self.show_ucs_icon,
|
||||
show_properties: self.show_properties,
|
||||
show_block_palette,
|
||||
show_file_tabs: self.show_file_tabs,
|
||||
show_layout_tabs: self.show_layout_tabs,
|
||||
}
|
||||
@@ -398,6 +402,7 @@ impl Ribbon {
|
||||
is_start: bool,
|
||||
undo_count: usize,
|
||||
redo_count: usize,
|
||||
show_block_palette: bool,
|
||||
) -> Element<'_, Message> {
|
||||
// ── Quick-access file commands + undo/redo, one merged flow ────────
|
||||
let lead = iced::widget::Row::with_children(vec![
|
||||
@@ -578,7 +583,7 @@ impl Ribbon {
|
||||
let panels: Vec<Panel<'_>> = groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let ts = self.toggle_state();
|
||||
let ts = self.toggle_state(show_block_palette);
|
||||
Panel {
|
||||
id: g.title.to_string(),
|
||||
full: render_group(
|
||||
@@ -1555,3 +1560,22 @@ impl Default for Ribbon {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::widgets::is_active_tool;
|
||||
use super::Ribbon;
|
||||
|
||||
#[test]
|
||||
fn block_palette_highlight_follows_the_app_state_passed_to_the_view() {
|
||||
// The Block Palette button highlight is derived from the app's
|
||||
// authoritative `show_block_palette`, threaded into the view at render
|
||||
// time: the ribbon keeps no second copy that a BLOCKPALETTE / close
|
||||
// handler must remember to keep in sync.
|
||||
let ribbon = Ribbon::default();
|
||||
let on = ribbon.toggle_state(true);
|
||||
assert!(is_active_tool("BLOCKPALETTE", &None, &on));
|
||||
let off = ribbon.toggle_state(false);
|
||||
assert!(!is_active_tool("BLOCKPALETTE", &None, &off));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ pub(super) struct ToggleState {
|
||||
pub show_viewcube: bool,
|
||||
pub show_ucs_icon: bool,
|
||||
pub show_properties: bool,
|
||||
pub show_block_palette: bool,
|
||||
pub show_file_tabs: bool,
|
||||
pub show_layout_tabs: bool,
|
||||
}
|
||||
@@ -359,6 +360,7 @@ pub(super) fn is_active_tool(
|
||||
"NAVVCUBE" => state.show_viewcube,
|
||||
"UCSICON" => state.show_ucs_icon,
|
||||
"PROPERTIES" => state.show_properties,
|
||||
"BLOCKPALETTE" => state.show_block_palette,
|
||||
"FILETAB" => state.show_file_tabs,
|
||||
"LAYOUTTAB" => state.show_layout_tabs,
|
||||
id => active_tool.as_deref() == Some(id),
|
||||
|
||||
+10
-33
@@ -328,10 +328,7 @@ pub fn view_window<'a>(
|
||||
|
||||
let mk_field = |fld: DsField, val: &'a str| -> Element<'a, Message> {
|
||||
if vals.read_only {
|
||||
container(text(val).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(100)
|
||||
.into()
|
||||
crate::ui::read_only::field(val, 11.0, Length::Fixed(100.0))
|
||||
} else {
|
||||
text_input("", val)
|
||||
.on_input(move |s| Message::DsEdit(fld.clone(), s))
|
||||
@@ -351,10 +348,7 @@ pub fn view_window<'a>(
|
||||
.width(100)
|
||||
.into()
|
||||
} else {
|
||||
container(text(val).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(100)
|
||||
.into()
|
||||
crate::ui::read_only::field(val, 11.0, Length::Fixed(100.0))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,10 +384,7 @@ pub fn view_window<'a>(
|
||||
label: val.to_string(),
|
||||
});
|
||||
let choice: Element<'a, Message> = if vals.read_only {
|
||||
container(text(cur.to_string()).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(150)
|
||||
.into()
|
||||
crate::ui::read_only::field(cur.to_string().as_str(), 11.0, Length::Fixed(150.0))
|
||||
} else {
|
||||
iced::widget::pick_list(Some(cur), options, |value| value.to_string())
|
||||
.on_select(move |chosen: DimEnumChoice| {
|
||||
@@ -476,8 +467,7 @@ pub fn view_window<'a>(
|
||||
Message::DsZeroBase(target.clone(), choice.code.parse().unwrap_or(0))
|
||||
}).into()
|
||||
} else {
|
||||
container(text(feet_current.to_string()).size(11).style(muted_style))
|
||||
.padding([4, 7]).width(210).into()
|
||||
crate::ui::read_only::field(feet_current.to_string().as_str(), 11.0, Length::Fixed(210.0))
|
||||
};
|
||||
row![lbl(t!("Feet and inches")), list]
|
||||
.spacing(8).align_y(iced::Center).into()
|
||||
@@ -513,9 +503,7 @@ pub fn view_window<'a>(
|
||||
if vals.read_only {
|
||||
return row![
|
||||
lbl(label),
|
||||
container(text(_val).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(150)
|
||||
crate::ui::read_only::field(_val, 11.0, Length::Fixed(150.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
@@ -552,10 +540,7 @@ pub fn view_window<'a>(
|
||||
field: &'static str|
|
||||
-> Element<'a, Message> {
|
||||
let list: Element<'a, Message> = if vals.read_only {
|
||||
container(text(selected).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(150)
|
||||
.into()
|
||||
crate::ui::read_only::field(&selected, 11.0, Length::Fixed(150.0))
|
||||
} else {
|
||||
iced::widget::pick_list(Some(selected), options, |value| value.to_string())
|
||||
.on_select(move |value| Message::DsSetHandle { field, value })
|
||||
@@ -581,10 +566,7 @@ pub fn view_window<'a>(
|
||||
let list: Element<'a, Message> = if enabled && !vals.read_only {
|
||||
list.on_select(move |value| Message::DsSetHandle { field, value }).into()
|
||||
} else {
|
||||
container(text(selected).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(150)
|
||||
.into()
|
||||
crate::ui::read_only::field(&selected, 11.0, Length::Fixed(150.0))
|
||||
};
|
||||
row![lbl(label), list]
|
||||
.spacing(8)
|
||||
@@ -593,10 +575,7 @@ pub fn view_window<'a>(
|
||||
};
|
||||
|
||||
let text_style_field: Element<'a, Message> = if vals.read_only {
|
||||
container(text(vals.dimtxsty).size(11).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(150)
|
||||
.into()
|
||||
crate::ui::read_only::field(&vals.dimtxsty, 11.0, Length::Fixed(150.0))
|
||||
} else {
|
||||
iced::widget::pick_list(
|
||||
Some(vals.dimtxsty.to_string()),
|
||||
@@ -631,8 +610,7 @@ pub fn view_window<'a>(
|
||||
center_choices[0].clone()
|
||||
};
|
||||
let center_method_field: Element<'a, Message> = if vals.read_only {
|
||||
container(text(center_current.to_string()).size(11).style(muted_style))
|
||||
.padding([4, 7]).width(150).into()
|
||||
crate::ui::read_only::field(center_current.to_string().as_str(), 11.0, Length::Fixed(150.0))
|
||||
} else {
|
||||
iced::widget::pick_list(Some(center_current), center_choices, |choice| choice.to_string())
|
||||
.on_select(|choice| Message::DsCenterMarkMode(choice.code))
|
||||
@@ -659,8 +637,7 @@ pub fn view_window<'a>(
|
||||
tolerance_choices[0].clone()
|
||||
};
|
||||
let tolerance_method_field: Element<'a, Message> = if vals.read_only {
|
||||
container(text(tolerance_current.to_string()).size(11).style(muted_style))
|
||||
.padding([4, 7]).width(150).into()
|
||||
crate::ui::read_only::field(tolerance_current.to_string().as_str(), 11.0, Length::Fixed(150.0))
|
||||
} else {
|
||||
iced::widget::pick_list(
|
||||
Some(tolerance_current),
|
||||
|
||||
@@ -98,7 +98,7 @@ fn input_row<'a>(
|
||||
fn readonly_row<'a>(label: Cow<'static, str>, value: Cow<'static, str>) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(165),
|
||||
container(text(value).size(11).style(muted_style)).padding([5, 7]),
|
||||
crate::ui::read_only::field(value.as_ref(), 11.0, Length::Fixed(190.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
|
||||
@@ -220,7 +220,7 @@ fn input_row<'a>(
|
||||
read_only: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let control: Element<'a, Message> = if read_only {
|
||||
container(text(value).size(11)).padding([5, 7]).width(180).into()
|
||||
crate::ui::read_only::field(value, 11.0, Length::Fixed(180.0))
|
||||
} else {
|
||||
text_input(placeholder.as_ref(), value)
|
||||
.on_input(move |value| Message::TextStyleEdit { field, value })
|
||||
|
||||
@@ -401,9 +401,11 @@ fn drop_row_enabled<'a>(
|
||||
}
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(92),
|
||||
container(text(selected.map(|choice| choice.to_string()).unwrap_or_default()).size(12).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(width),
|
||||
crate::ui::read_only::field(
|
||||
selected.map(|choice| choice.to_string()).unwrap_or_default().as_str(),
|
||||
12.0,
|
||||
width,
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
@@ -442,9 +444,7 @@ fn field_row_enabled<'a>(
|
||||
}
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(92),
|
||||
container(text(value.to_string()).size(12).style(muted_style))
|
||||
.padding([4, 7])
|
||||
.width(width as f32),
|
||||
crate::ui::read_only::field(value, 12.0, Length::Fixed(width as f32)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
|
||||
Reference in new issue
Block a user