feat(blocks): implement Block Definition dialog (BLOCK/BMAKE) with on-screen sequencing

Implement the complete Block Definition dialog window launched via BLOCK
and BMAKE commands, featuring standard CAD layout, workflows, and entity
creation pipelines.

- Dialog UI & Layout:
  - 2-column compact CAD grid with synchronized vertical heights (580 × 390px).
  - Editable combobox with existing block names, invalid character validation,
    and inline redefinition badge.
  - Multi-line text editor widget for block descriptions (replacing single-line input).
  - Quick Select (QSELECT) button integration restoring dialog state upon completion.
  - Confirmation guard when attempting to redefine an existing block definition.
  - Centered radio buttons, clean coordinate inputs, and accessible keyboard shortcuts.

- Base Point & On-Screen Workflow:
  - BlockOnScreenCommand strictly enforces prompt sequencing: Base point is
    prompted before object selection.
  - Dynamically recalculates world_to_block and block_to_world affine transforms
    when base point is specified on-screen, preventing stale (0,0,0) normalization.
  - Supports Retain, Convert to block (INSERT placement), and Delete modes.

- Explode Enforcement:
  - Guarded EXPLODE command to prevent decomposing blocks flagged as non-explodable.

- Dynamic Themed Icons:
  - Added pick_point.svg, select_objects.svg, quick_select.svg, and warning_triangle.svg.
  - Routed all icons through the semantic theming engine so line work (#B4B6B9)
    and accents (#6DB7ED) adapt automatically to the active theme.

- Tests:
  - Added unit tests for retain/convert/delete modes, redefinition, explodable flag,
    on-screen base point transform recalculation, and command sequencing.
This commit is contained in:
Karim Jerbi committed 2026-09-22 17:05:15 +01:00
1 parent 889a19c362
commit 652f4d6bbc
19 files changed
+2316 -318

No files matched your search

+696
View File
@@ -0,0 +1,696 @@
//! Block Definition dialog window — create or redefine a block definition.
//! Prompted when the user runs the `BLOCK` or `BMAKE` command.
use std::fmt;
use acadrust::Handle;
use iced::widget::{
button, checkbox, column, combo_box, container, mouse_area, pick_list, radio, row, stack, text,
text_editor, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::modules::draw::units;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg");
static ICON_SELECT_OBJECTS: &[u8] =
include_bytes!("../../../assets/icons/blocks/select_objects.svg");
static ICON_QUICK_SELECT: &[u8] = include_bytes!("../../../assets/icons/blocks/quick_select.svg");
static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg");
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum BlockObjectMode {
Retain,
Convert,
Delete,
}
impl Default for BlockObjectMode {
fn default() -> Self {
BlockObjectMode::Convert
}
}
/// Dropdown entry for the Block Unit picker.
#[derive(Clone, PartialEq, Eq)]
pub struct UnitChoice {
pub code: i16,
pub label: &'static str,
}
impl fmt::Display for UnitChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = crate::i18n::translate(self.label);
f.write_str(name.as_ref())
}
}
/// The Block Definition dialog's working copy.
pub struct BlockDefinitionState {
pub name: String,
pub existing_names: Vec<String>,
pub name_combo: combo_box::State<String>,
pub base_point_specify_onscreen: bool,
pub base_point_x: String,
pub base_point_y: String,
pub base_point_z: String,
pub objects_specify_onscreen: bool,
pub selected_handles: Vec<Handle>,
pub object_mode: BlockObjectMode,
pub annotative: bool,
pub match_orientation: bool,
pub scale_uniformly: bool,
pub allow_exploding: bool,
pub unit: i16,
pub description: String,
pub description_content: text_editor::Content,
pub hyperlink_url: String,
pub hyperlink_desc: String,
pub error_message: Option<String>,
pub confirm_redefine: Option<String>,
}
impl Clone for BlockDefinitionState {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
existing_names: self.existing_names.clone(),
name_combo: self.name_combo.clone(),
base_point_specify_onscreen: self.base_point_specify_onscreen,
base_point_x: self.base_point_x.clone(),
base_point_y: self.base_point_y.clone(),
base_point_z: self.base_point_z.clone(),
objects_specify_onscreen: self.objects_specify_onscreen,
selected_handles: self.selected_handles.clone(),
object_mode: self.object_mode,
annotative: self.annotative,
match_orientation: self.match_orientation,
scale_uniformly: self.scale_uniformly,
allow_exploding: self.allow_exploding,
unit: self.unit,
description: self.description.clone(),
description_content: text_editor::Content::with_text(&self.description),
hyperlink_url: self.hyperlink_url.clone(),
hyperlink_desc: self.hyperlink_desc.clone(),
error_message: self.error_message.clone(),
confirm_redefine: self.confirm_redefine.clone(),
}
}
}
impl BlockDefinitionState {
pub fn new(existing_names: Vec<String>, pre_selected: Vec<Handle>, default_unit: i16) -> Self {
Self {
name: String::new(),
name_combo: combo_box::State::new(existing_names.clone()),
existing_names,
base_point_specify_onscreen: false,
base_point_x: "0.0000".to_string(),
base_point_y: "0.0000".to_string(),
base_point_z: "0.0000".to_string(),
objects_specify_onscreen: false,
selected_handles: pre_selected,
object_mode: BlockObjectMode::Convert,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: true,
unit: default_unit,
description: String::new(),
description_content: text_editor::Content::new(),
hyperlink_url: String::new(),
hyperlink_desc: String::new(),
error_message: None,
confirm_redefine: None,
}
}
pub fn parse_base_point(&self) -> glam::DVec3 {
let x = self.base_point_x.trim().parse::<f64>().unwrap_or(0.0);
let y = self.base_point_y.trim().parse::<f64>().unwrap_or(0.0);
let z = self.base_point_z.trim().parse::<f64>().unwrap_or(0.0);
glam::DVec3::new(x, y, z)
}
pub fn is_existing_name(&self) -> bool {
let trimmed = self.name.trim();
!trimmed.is_empty()
&& self
.existing_names
.iter()
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
}
pub fn invalid_name_char(&self) -> Option<char> {
self.name.chars().find(|&c| {
matches!(
c,
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '=' | '`'
)
})
}
}
fn group<'a>(
title: impl Into<String>,
body: impl Into<Element<'a, Message>>,
height: impl Into<Length>,
) -> Element<'a, Message> {
container(
column![
text(title.into())
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
body.into(),
]
.spacing(4),
)
.padding([5, 8])
.width(Fill)
.height(height)
.style(|theme: &Theme| container::Style {
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
})
.into()
}
fn labeled_checkbox<'a>(
label: impl Into<String>,
is_checked: bool,
on_toggle: impl Fn(bool) -> Message + 'a,
) -> Element<'a, Message> {
row![
checkbox(is_checked).on_toggle(on_toggle).size(14),
text(label.into()).size(11),
]
.spacing(5)
.align_y(iced::Center)
.into()
}
pub fn view_window<'a>(
state: &'a BlockDefinitionState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// ── Name Row ─────────────────────────────────────────────────────────────
let name_combo = combo_box(
&state.name_combo,
t!("Block name").as_ref(),
if state.name.is_empty() {
None
} else {
Some(&state.name)
},
|chosen: String| Message::BlockDefNameSelect(chosen),
)
.on_input(Message::BlockDefName)
.size(11)
.padding([3, 6])
.width(Fill);
let name_hint: Option<Element<'a, Message>> = if let Some(invalid_char) =
state.invalid_name_char()
{
Some(
text(crate::tf!("Invalid character '{invalid_char}' in block name (\\ / : * ? \" < > | = ` are not allowed)"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().danger.base.color),
})
.into(),
)
} else if state.is_existing_name() {
Some(
text(crate::tf!(
"Block \"{}\" already exists — will redefine upon OK",
state.name.trim()
))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().primary.base.color),
})
.into(),
)
} else {
None
};
let mut name_column = column![text(t!("Name:")).size(11).style(muted_style), name_combo,]
.spacing(3)
.width(Fill);
if let Some(hint) = name_hint {
name_column = name_column.push(hint);
}
let name_section = name_column;
// ── Group: Base Point ─────────────────────────────────────────────────────
let base_onscreen = labeled_checkbox(
t!("Specify On-screen"),
state.base_point_specify_onscreen,
Message::BlockDefBaseOnScreen,
);
let pick_pt_icon = crate::ui::icons::semantic(ICON_PICK_POINT, 15.0);
let mut pick_pt_btn = button(
row![pick_pt_icon, text(t!("Pick point")).size(11)]
.spacing(5)
.align_y(iced::Center),
)
.padding([3, 8]);
if !state.base_point_specify_onscreen {
pick_pt_btn = pick_pt_btn
.on_press(Message::BlockDefPickPoint)
.style(button_style(false));
}
let coord_row = |axis: &'static str, val: &'a str, on_input: fn(String) -> Message| {
let is_enabled = !state.base_point_specify_onscreen;
let input: Element<'a, Message> = if is_enabled {
text_input("", val)
.on_input(on_input)
.on_submit(Message::BlockDefApply)
.style(field_style)
.size(11)
.padding([2, 5])
.width(Fill)
.into()
} else {
crate::ui::read_only::field(val, 11.0, Fill)
};
row![
text(axis)
.size(11)
.style(muted_style)
.width(Length::Fixed(16.0)),
input,
]
.spacing(4)
.align_y(iced::Center)
};
let base_point_group = group(
t!("Base point").into_owned(),
column![
base_onscreen,
Space::new().height(1),
pick_pt_btn,
Space::new().height(2),
coord_row("X:", &state.base_point_x, Message::BlockDefBaseX),
coord_row("Y:", &state.base_point_y, Message::BlockDefBaseY),
coord_row("Z:", &state.base_point_z, Message::BlockDefBaseZ),
]
.spacing(3),
Length::Fixed(152.0),
);
// ── Group: Objects ────────────────────────────────────────────────────────
let objects_onscreen = labeled_checkbox(
t!("Specify On-screen"),
state.objects_specify_onscreen,
Message::BlockDefObjectsOnScreen,
);
let sel_obj_icon = crate::ui::icons::semantic(ICON_SELECT_OBJECTS, 15.0);
let mut sel_obj_btn = button(
row![sel_obj_icon, text(t!("Select objects")).size(11)]
.spacing(5)
.align_y(iced::Center),
)
.padding([3, 8]);
if !state.objects_specify_onscreen {
sel_obj_btn = sel_obj_btn
.on_press(Message::BlockDefSelectObjects)
.style(button_style(false));
}
let qsel_icon = crate::ui::icons::semantic(ICON_QUICK_SELECT, 15.0);
let mut qsel_btn = button(qsel_icon).padding([3, 5]).style(button_style(false));
if !state.objects_specify_onscreen {
qsel_btn = qsel_btn.on_press(Message::BlockDefQuickSelect);
}
let btns_row = row![sel_obj_btn, qsel_btn].spacing(5).align_y(iced::Center);
let radios = column![
radio(
t!("Retain"),
BlockObjectMode::Retain,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
t!("Convert to block"),
BlockObjectMode::Convert,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
t!("Delete"),
BlockObjectMode::Delete,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
]
.spacing(2);
let count = state.selected_handles.len();
let status_row: Element<'a, Message> = if count == 0 {
row![
crate::ui::icons::semantic(ICON_WARN, 13.0),
text(t!("No objects selected"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.75)),
}),
]
.spacing(5)
.align_y(iced::Center)
.into()
} else {
row![text(crate::tf!("{count} objects selected"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().primary.base.color),
}),]
.spacing(5)
.align_y(iced::Center)
.into()
};
let objects_group = group(
t!("Objects").into_owned(),
column![
objects_onscreen,
Space::new().height(1),
btns_row,
Space::new().height(2),
radios,
Space::new().height(2),
status_row,
]
.spacing(2),
Length::Fixed(152.0),
);
// ── Group: Behavior ───────────────────────────────────────────────────────
let annotative_cb = labeled_checkbox(
t!("Annotative"),
state.annotative,
Message::BlockDefAnnotative,
);
let match_orient_cb: Element<'a, Message> = if state.annotative {
row![
Space::new().width(16),
checkbox(state.match_orientation)
.on_toggle(Message::BlockDefMatchOrientation)
.size(13),
column![
text(t!("Match block orientation")).size(10),
text(t!("to layout")).size(10),
]
.spacing(1),
]
.spacing(5)
.align_y(iced::Center)
.into()
} else {
row![
Space::new().width(16),
checkbox(false).size(13),
column![
text(t!("Match block orientation"))
.size(10)
.style(muted_style),
text(t!("to layout")).size(10).style(muted_style),
]
.spacing(1),
]
.spacing(5)
.align_y(iced::Center)
.into()
};
let scale_uniform_cb = labeled_checkbox(
t!("Scale uniformly"),
state.scale_uniformly,
Message::BlockDefScaleUniformly,
);
let allow_explode_cb = labeled_checkbox(
t!("Allow exploding"),
state.allow_exploding,
Message::BlockDefAllowExploding,
);
let behavior_group = group(
t!("Behavior").into_owned(),
column![
annotative_cb,
Space::new().height(2),
match_orient_cb,
Space::new().height(4),
scale_uniform_cb,
Space::new().height(4),
allow_explode_cb,
]
.spacing(3),
Length::Fixed(152.0),
);
// ── Group: Settings (Column 1 Bottom) ─────────────────────────────────────
let unit_options: Vec<UnitChoice> = units::all()
.map(|(code, label)| UnitChoice { code, label })
.collect();
let selected_unit = unit_options
.iter()
.find(|choice| choice.code == state.unit)
.cloned();
let unit_picker = pick_list(selected_unit, unit_options, |choice| choice.to_string())
.on_select(|choice| Message::BlockDefUnit(choice.code))
.text_size(11)
.padding([2, 5])
.width(Fill);
let hyperlink_btn = button(
text(if state.hyperlink_url.is_empty() {
t!("Hyperlink...")
} else {
t!("Hyperlink*")
})
.size(11),
)
.on_press(Message::BlockDefHyperlink)
.style(button_style(false))
.padding([3, 14]);
let settings_group = group(
t!("Settings").into_owned(),
column![
text(t!("Block unit:")).size(10).style(muted_style),
unit_picker,
Space::new().height(6),
row![
Space::new().width(Fill),
hyperlink_btn,
Space::new().width(Fill)
],
]
.spacing(3),
Length::Fixed(102.0),
);
// ── Group: Description (Column 2 Bottom) ──────────────────────────────────
let desc_editor = text_editor(&state.description_content)
.on_action(Message::BlockDefDescriptionAction)
.placeholder("Enter block description...")
.size(11)
.padding([4, 6])
.height(Fill)
.style(|theme: &Theme, status| {
let palette = theme.palette();
let border_color = match status {
text_editor::Status::Focused { .. } => palette.primary.base.color,
_ => palette.background.neutral.color,
};
text_editor::Style {
background: Background::Color(palette.background.base.color),
border: Border {
color: border_color,
width: 1.0,
radius: 3.0.into(),
},
placeholder: palette.background.base.text.scale_alpha(0.48),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
});
let description_group = group(
t!("Description").into_owned(),
desc_editor,
Length::Fixed(102.0),
);
// ── Layout Assembly: Left Column & Right Column ───────────────────────────
let left_column = column![base_point_group, settings_group,]
.spacing(6)
.width(Length::FillPortion(1));
let top_right_row = row![objects_group, behavior_group,].spacing(6).width(Fill);
let right_column = column![top_right_row, description_group,]
.spacing(6)
.width(Length::FillPortion(2));
let main_body = row![left_column, right_column].spacing(6).width(Fill);
// ── Footer Bar ────────────────────────────────────────────────────────────
let actions = row![
Space::new().width(Fill),
button(text(t!("OK")).size(11))
.on_press(Message::BlockDefApply)
.style(button_style(true))
.padding([4, 16]),
button(text(t!("Cancel")).size(11))
.on_press(Message::CloseModal)
.style(button_style(false))
.padding([4, 12]),
button(text(t!("Help")).size(11))
.on_press(Message::BlockDefHelp)
.style(button_style(false))
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
// ── Error Banner ──────────────────────────────────────────────────────────
let mut main_column = column![name_section, main_body, actions,]
.spacing(6)
.padding([8, 10])
.width(sizing.width);
if let Some(err) = &state.error_message {
let err_banner = container(
row![
crate::ui::icons::semantic(ICON_WARN, 16.0),
text(err.clone())
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text),
}),
Space::new().width(Fill),
button(text("×").size(12))
.on_press(Message::BlockDefDismissError)
.style(button_style(false))
.padding([1, 6]),
]
.spacing(8)
.align_y(iced::Center),
)
.padding([6, 10])
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().danger.base.color.scale_alpha(0.18),
)),
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().danger.base.color,
},
..Default::default()
});
main_column = column![err_banner, main_column]
.spacing(8)
.width(sizing.width)
.padding(12);
}
let main_content = main_column.into();
// ── Confirm Redefinition Guard ────────────────────────────────────────────
if let Some(name) = &state.confirm_redefine {
let question = crate::tf!("Block \"{name}\" already exists.\nDo you want to redefine it?");
let shield = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.strongest.color.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(Message::BlockDefConfirmRedefine(false));
let panel = container(
column![
text(question)
.size(13)
.align_x(iced::alignment::Horizontal::Center),
Space::new().height(16),
row![
button(text(t!("Yes")).size(12))
.on_press(Message::BlockDefConfirmRedefine(true))
.padding([6, 20])
.style(button_style(false)),
// Default / safe focus on "No"
button(text(t!("No")).size(12))
.on_press(Message::BlockDefConfirmRedefine(false))
.padding([6, 22])
.style(button_style(true)),
]
.spacing(12)
.align_y(iced::Center),
]
.spacing(0)
.align_x(iced::alignment::Horizontal::Center),
)
.padding([20, 24])
.style(container::rounded_box);
return stack![
main_content,
shield,
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Fill)
.center_y(Fill),
]
.into();
}
main_content
}
+1
View File
@@ -1,4 +1,5 @@
pub mod about;
pub mod block_definition;
pub mod block_palette;
pub mod browser;
pub mod layout_manager;