Files
OneCAD-Draft/src/ui/window/block_definition.rs
T
ramox81andClaude Opus 5.5 d69a5f0ac4 Block Definition dialog: preview, empty-block band, open in editor
The name card draws the selected objects, or the existing definition the
name picks. OK with no objects shows an in-window band offering to select
objects or to define an empty block. A switch opens the new block in the
block editor once it is defined; it is remembered with the other dialog
choices. Automation gains block_def_dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:50:35 +03:00

798 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Block Definition dialog window — create or redefine a block definition.
//! Prompted when the user runs the `BLOCK` or `BMAKE` command.
use std::fmt;
use codec::Handle;
use iced::widget::{
button, checkbox, column, combo_box, container, mouse_area, pick_list, row, stack, text,
text_editor, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::scene::WireModel;
use crate::modules::draw::units;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, dialog_button, field_style, form_radio};
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>,
pub desc_placeholder: String,
/// The selected objects, or the chosen existing definition, as drawn in
/// the name card.
pub preview: Vec<WireModel>,
/// OK was pressed with no objects: the band offers to select some or to
/// define an empty block.
pub empty_prompt: bool,
/// Open the new block in the block editor once it is defined.
pub open_in_editor: bool,
}
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(),
desc_placeholder: self.desc_placeholder.clone(),
preview: self.preview.clone(),
empty_prompt: self.empty_prompt,
open_in_editor: self.open_in_editor,
}
}
}
impl BlockDefinitionState {
pub fn new(existing_names: Vec<String>, pre_selected: Vec<Handle>, default_unit: i16) -> Self {
// The on-screen switches, the conversion and Allow exploding start
// where the dialog was last confirmed.
let remembered = *crate::modules::insert::block_cmdline::defaults();
Self {
name: String::new(),
name_combo: combo_box::State::new(existing_names.clone()),
existing_names,
base_point_specify_onscreen: remembered.base_on_screen,
base_point_x: "0".to_string(),
base_point_y: "0".to_string(),
base_point_z: "0".to_string(),
objects_specify_onscreen: remembered.objects_on_screen,
selected_handles: pre_selected,
object_mode: remembered.mode,
annotative: false,
match_orientation: false,
scale_uniformly: false,
allow_exploding: remembered.allow_exploding,
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,
desc_placeholder: crate::t!("Enter block description...").into_owned(),
preview: Vec::new(),
empty_prompt: false,
open_in_editor: remembered.open_in_editor,
}
}
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)
}
/// Prefill the base point fields from an existing definition, so
/// redefining a block starts from the base it already has (0.0000 for a
/// new block). Formatted the same way the pick point button writes them.
pub fn set_base_point(&mut self, base: glam::DVec3) {
self.base_point_x = format!("{:.4}", base.x);
self.base_point_y = format!("{:.4}", base.y);
self.base_point_z = format!("{:.4}", base.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> {
invalid_block_name_char(&self.name)
}
}
/// The first character a block name may not hold: \ < > / ? " : ; * | , = `
pub fn invalid_block_name_char(name: &str) -> Option<char> {
name.chars().find(|&c| {
matches!(
c,
'\\' | '<' | '>' | '/' | '?' | '"' | ':' | ';' | '*' | '|' | ',' | '=' | '`'
)
})
}
pub(crate) 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()
}
pub(crate) 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()
}
/// The in-window warning shown when OK is pressed with no objects: select
/// some, or define an empty block.
fn empty_band<'a>() -> Element<'a, Message> {
container(
row![
crate::ui::icons::semantic(ICON_WARN, 15.0),
text(t!("No objects are selected for the block."))
.size(11)
.width(Fill),
button(text(t!("Select objects")).size(11))
.on_press(Message::BlockDefSelectObjects)
.style(button_style(false))
.padding([4, 12]),
button(text(t!("Create empty block")).size(11))
.on_press(Message::BlockDefCreateEmpty)
.style(button_style(false))
.padding([4, 12]),
]
.spacing(10)
.align_y(iced::Center),
)
.padding([7, 10])
.width(Fill)
.style(|theme: &Theme| {
let warn = theme.palette().warning.base.color;
container::Style {
background: Some(Background::Color(warn.scale_alpha(0.16))),
border: Border {
width: 1.0,
radius: 6.0.into(),
color: warn,
},
..Default::default()
}
})
.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);
}
// The name card carries a live preview of the selected objects, or of
// the existing definition the name picks.
let preview = container(super::block_palette::preview_canvas(&state.preview, 86.0))
.padding(3)
.width(Length::Fixed(150.0))
.height(Length::Fixed(92.0))
.style(crate::ui::window::pdf_dialogs::well_style);
let name_section = container(
row![name_column, preview]
.spacing(12)
.align_y(iced::Center),
)
.padding([8, 10])
.width(Fill)
.style(crate::ui::window::pdf_dialogs::card_style);
// ── 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![
form_radio(
t!("Retain"),
BlockObjectMode::Retain,
Some(state.object_mode),
Message::BlockDefObjectMode,
),
form_radio(
t!("Convert to block"),
BlockObjectMode::Convert,
Some(state.object_mode),
Message::BlockDefObjectMode,
),
form_radio(
t!("Delete"),
BlockObjectMode::Delete,
Some(state.object_mode),
Message::BlockDefObjectMode,
),
]
.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(&state.desc_placeholder)
.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![
button(text("?").size(12))
.on_press(Message::BlockDefHelp)
.style(button_style(false))
.padding([3, 10]),
Space::new().width(Fill),
dialog_button(t!("Cancel"), Message::CloseModal, false),
dialog_button(t!("OK"), Message::BlockDefApply, true),
]
.spacing(8)
.align_y(iced::Center);
// ── Open in the block editor ─────────────────────────────────────────────
let editor_card = container(
column![
row![
iced::widget::toggler(state.open_in_editor)
.on_toggle(Message::BlockDefOpenInEditor)
.size(16.0),
text(t!("Open in block editor after creating")).size(11),
]
.spacing(8)
.align_y(iced::Center),
text(t!(
"Specify On-screen, the object option, Allow exploding and this switch are remembered for the next time."
))
.size(10)
.style(muted_style),
]
.spacing(4),
)
.padding([8, 10])
.width(Fill)
.style(crate::ui::window::pdf_dialogs::card_style);
// ── Error Banner ──────────────────────────────────────────────────────────
let mut main_column = column![name_section];
if state.empty_prompt {
main_column = main_column.push(empty_band());
}
let mut main_column = main_column
.push(main_body)
.push(editor_card)
.push(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 _ = name;
let question = t!("The block definition has changed. Do 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![
dialog_button(
t!("Redefine"),
Message::BlockDefConfirmRedefine(true),
false
),
// Default / safe focus on "No"
dialog_button(t!("No"), Message::BlockDefConfirmRedefine(false), 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
}