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:
Hakan Seven committed 2026-08-15 20:17:43 +03:00
commit f247d15f6e
110 files changed
+32159 -1254

No files matched your search

+32 -10
View File
@@ -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);
}
}
+1
View File
@@ -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
View File
@@ -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 &section.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(&sections);
// 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() {
+43
View File
@@ -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
View File
@@ -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));
}
}
+2
View File
@@ -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
View File
@@ -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),
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 })
+6 -6
View File
@@ -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)