Merge remote-tracking branch 'origin/main' into codex/pdf-attach-parity

# Conflicts:
#	locales/ar-SA/opencadstudio.ftl
#	locales/bg-BG/opencadstudio.ftl
#	locales/cs-CZ/opencadstudio.ftl
#	locales/de-DE/opencadstudio.ftl
#	locales/el-GR/opencadstudio.ftl
#	locales/en-US/opencadstudio.ftl
#	locales/es-ES/opencadstudio.ftl
#	locales/fi-FI/opencadstudio.ftl
#	locales/fr-FR/opencadstudio.ftl
#	locales/hi-IN/opencadstudio.ftl
#	locales/hu-HU/opencadstudio.ftl
#	locales/it-IT/opencadstudio.ftl
#	locales/ja-JP/opencadstudio.ftl
#	locales/ko-KR/opencadstudio.ftl
#	locales/nl-NL/opencadstudio.ftl
#	locales/pl-PL/opencadstudio.ftl
#	locales/pt-BR/opencadstudio.ftl
#	locales/ru-RU/opencadstudio.ftl
#	locales/tr-TR/opencadstudio.ftl
#	locales/zh-CN/opencadstudio.ftl
#	locales/zh-TW/opencadstudio.ftl
#	src/app/control/actions.rs
#	src/app/mod.rs
#	src/app/update/mod.rs
#	src/app/view/modal.rs
#	src/io/xref.rs
#	src/ui/window/mod.rs
This commit is contained in:
ramox81 committed 2026-09-24 19:38:48 +03:00
commit c1ecb0edaf
77 files changed
+5695 -606

No files matched your search

+35 -1
View File
@@ -7,10 +7,44 @@
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, container, pick_list, row, text, text_input, Space};
use iced::widget::{button, container, pick_list, radio, row, text, text_input, Space};
use iced::{Background, Border, Element, Length, Theme};
use std::borrow::Cow;
/// Standard dialog footer action button (OK, Cancel, Apply, Close, Help, etc.),
/// matching the Drafting Settings / Options appearance: 12px text, [6, 18] padding,
/// themed primary or secondary style.
pub fn dialog_button<'a>(
label: impl text::IntoFragment<'a>,
on_press: Message,
primary: bool,
) -> button::Button<'a, Message> {
button(text(label).size(12))
.on_press(on_press)
.padding([6, 18])
.style(if primary {
button::primary
} else {
button::secondary
})
}
/// Unified radio button helper ensuring consistent 16px size for exact pixel-grid concentric alignment.
pub fn form_radio<'a, T>(
label: impl Into<String>,
value: T,
selected: Option<T>,
on_click: impl Fn(T) -> Message + 'a,
) -> radio::Radio<'a, Message>
where
T: Copy + Eq + 'a,
{
radio(label, value, selected, on_click)
.size(16)
.spacing(6)
.text_size(11)
}
/// Width of the label column in a `label : control` row.
pub const LABEL_WIDTH: f32 = 92.0;
+2 -2
View File
@@ -168,7 +168,7 @@ pub fn view_window<'a>(
Some(relative),
Message::PointStyleSizeRelative,
)
.size(15)
.size(16)
.text_size(13),
radio(
t!("Set Size in Absolute Units"),
@@ -176,7 +176,7 @@ pub fn view_window<'a>(
Some(relative),
Message::PointStyleSizeRelative,
)
.size(15)
.size(16)
.text_size(13),
]
.spacing(6);
+14 -38
View File
@@ -5,7 +5,7 @@ use std::fmt;
use acadrust::Handle;
use iced::widget::{
button, checkbox, column, combo_box, container, mouse_area, pick_list, radio, row, stack, text,
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};
@@ -14,7 +14,7 @@ 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};
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] =
@@ -353,33 +353,24 @@ pub fn view_window<'a>(
let btns_row = row![sel_obj_btn, qsel_btn].spacing(5).align_y(iced::Center);
let radios = column![
radio(
form_radio(
t!("Retain"),
BlockObjectMode::Retain,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
),
form_radio(
t!("Convert to block"),
BlockObjectMode::Convert,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
),
form_radio(
t!("Delete"),
BlockObjectMode::Delete,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
),
]
.spacing(2);
@@ -580,20 +571,11 @@ pub fn view_window<'a>(
// ── 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]),
dialog_button(t!("OK"), Message::BlockDefApply, true),
dialog_button(t!("Cancel"), Message::CloseModal, false),
dialog_button(t!("Help"), Message::BlockDefHelp, false),
]
.spacing(6)
.spacing(8)
.align_y(iced::Center);
// ── Error Banner ──────────────────────────────────────────────────────────
@@ -664,15 +646,9 @@ pub fn view_window<'a>(
.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)),
dialog_button(t!("Yes"), Message::BlockDefConfirmRedefine(true), false),
// Default / safe focus on "No"
button(text(t!("No")).size(12))
.on_press(Message::BlockDefConfirmRedefine(false))
.padding([6, 22])
.style(button_style(true)),
dialog_button(t!("No"), Message::BlockDefConfirmRedefine(false), true),
]
.spacing(12)
.align_y(iced::Center),
+15 -6
View File
@@ -17,6 +17,7 @@ fn muted_style(theme: &Theme) -> text::Style {
pub fn view_window<'a>(
fonts: &'a [String],
font_source: &'a str,
downloading: bool,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let content_width = if matches!(sizing.width, Length::Fill) {
@@ -33,16 +34,24 @@ pub fn view_window<'a>(
.iter()
.map(|name| text(format!(" {name}")).size(12).into())
.collect();
let download = button(
text(if downloading {
crate::t!("Downloading...")
} else {
crate::t!("Download available")
})
.size(12),
)
.on_press_maybe((!downloading).then_some(Message::MissingFontsDownload))
.style(button::primary)
.padding([6, 14]);
let actions = row![
Space::new().width(content_width),
button(text(crate::t!("Skip")).size(12))
.on_press(Message::MissingFontsDismiss)
.on_press_maybe((!downloading).then_some(Message::MissingFontsDismiss))
.style(button::secondary)
.padding([6, 14]),
button(text(crate::t!("Download")).size(12))
.on_press(Message::MissingFontsDownload)
.style(button::primary)
.padding([6, 14]),
download,
]
.spacing(8);
@@ -50,7 +59,7 @@ pub fn view_window<'a>(
column![
text(crate::t!("Missing fonts")).size(20),
text(crate::t!(
"This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?"
"This drawing uses fonts that are not installed on this machine. OpenCADStudio can download any redistributable fonts that exist in the selected repository; unavailable fonts will keep using a substitute."
))
.size(11)
.style(muted_style),
+2
View File
@@ -25,5 +25,7 @@ pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
pub mod pdf_dialogs;
pub mod xref_attach;
pub mod xref_help;
pub mod xref_manager;
pub mod wblock;
+492
View File
@@ -0,0 +1,492 @@
//! Write Block (WBLOCK) dialog window — write a block, selected entities, or entire drawing to an external DWG/DXF file.
use std::fmt;
use acadrust::Handle;
use iced::widget::{
button, column, combo_box, container, pick_list, row, text, 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::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 WblockSourceMode {
Block,
EntireDrawing,
Objects,
}
impl Default for WblockSourceMode {
fn default() -> Self {
WblockSourceMode::Objects
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WblockObjectMode {
Retain,
Convert,
Delete,
}
impl Default for WblockObjectMode {
fn default() -> Self {
WblockObjectMode::Retain
}
}
/// Dropdown entry for the 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 Write Block dialog's state.
pub struct WblockState {
pub source_mode: WblockSourceMode,
pub block_name: String,
pub existing_blocks: Vec<String>,
pub block_combo: combo_box::State<String>,
pub base_point_x: String,
pub base_point_y: String,
pub base_point_z: String,
pub selected_handles: Vec<Handle>,
pub object_mode: WblockObjectMode,
pub file_path: String,
pub unit: i16,
pub error_message: Option<String>,
}
impl Clone for WblockState {
fn clone(&self) -> Self {
Self {
source_mode: self.source_mode,
block_name: self.block_name.clone(),
existing_blocks: self.existing_blocks.clone(),
block_combo: self.block_combo.clone(),
base_point_x: self.base_point_x.clone(),
base_point_y: self.base_point_y.clone(),
base_point_z: self.base_point_z.clone(),
selected_handles: self.selected_handles.clone(),
object_mode: self.object_mode,
file_path: self.file_path.clone(),
unit: self.unit,
error_message: self.error_message.clone(),
}
}
}
impl WblockState {
pub fn new(
existing_blocks: Vec<String>,
pre_selected: Vec<Handle>,
default_unit: i16,
default_path: String,
) -> Self {
let default_block = existing_blocks.first().cloned().unwrap_or_default();
Self {
source_mode: WblockSourceMode::Objects,
block_name: default_block,
block_combo: combo_box::State::new(existing_blocks.clone()),
existing_blocks,
base_point_x: "0.00".to_string(),
base_point_y: "0.00".to_string(),
base_point_z: "0.00".to_string(),
selected_handles: pre_selected,
object_mode: WblockObjectMode::Retain,
file_path: default_path,
unit: default_unit,
error_message: 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)
}
}
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 fn view_window<'a>(
state: &'a WblockState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let is_objects = state.source_mode == WblockSourceMode::Objects;
let is_block = state.source_mode == WblockSourceMode::Block;
// ── Source: Block Radio & Dropdown ─────────────────────────────────────────
let block_radio = form_radio(
t!("Block:"),
WblockSourceMode::Block,
Some(state.source_mode),
Message::WblockSourceMode,
);
let block_dropdown: Element<'a, Message> = if is_block {
combo_box(
&state.block_combo,
"",
if state.block_name.is_empty() {
None
} else {
Some(&state.block_name)
},
|chosen: String| Message::WblockBlockSelect(chosen),
)
.on_input(Message::WblockBlockName)
.size(11)
.padding([2, 5])
.width(Fill)
.into()
} else {
crate::ui::read_only::field(&state.block_name, 11.0, Fill)
};
let block_row = row![block_radio, block_dropdown]
.spacing(8)
.width(Fill)
.align_y(iced::Center);
let entire_drawing_radio = form_radio(
t!("Entire drawing"),
WblockSourceMode::EntireDrawing,
Some(state.source_mode),
Message::WblockSourceMode,
);
let objects_radio = form_radio(
t!("Objects"),
WblockSourceMode::Objects,
Some(state.source_mode),
Message::WblockSourceMode,
);
// ── Source: Base Point Sub-group ──────────────────────────────────────────
let pick_pt_icon = crate::ui::icons::semantic(ICON_PICK_POINT, 14.0);
let mut pick_pt_btn = button(
row![pick_pt_icon, text(t!("Pick point")).size(11)]
.spacing(6)
.align_y(iced::Center),
)
.padding([3, 10]);
if is_objects {
pick_pt_btn = pick_pt_btn
.on_press(Message::WblockPickPoint)
.style(button_style(false));
} else {
pick_pt_btn = pick_pt_btn.style(button_style(false));
}
let coord_row = |axis: &'static str, val: &'a str, on_input: fn(String) -> Message| {
let input: Element<'a, Message> = if is_objects {
text_input("", val)
.on_input(on_input)
.on_submit(Message::WblockApply)
.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(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
})
.width(Length::Fixed(16.0)),
input,
]
.spacing(4)
.align_y(iced::Center)
};
let base_point_group = group(
t!("Base point").into_owned(),
column![
pick_pt_btn,
Space::new().height(3),
coord_row("X:", &state.base_point_x, Message::WblockBaseX),
coord_row("Y:", &state.base_point_y, Message::WblockBaseY),
coord_row("Z:", &state.base_point_z, Message::WblockBaseZ),
]
.spacing(3),
Length::Fixed(142.0),
);
// ── Source: Objects Sub-group ─────────────────────────────────────────────
let sel_obj_icon = crate::ui::icons::semantic(ICON_SELECT_OBJECTS, 14.0);
let mut sel_obj_btn = button(
row![sel_obj_icon, text(t!("Select objects")).size(11)]
.spacing(6)
.align_y(iced::Center),
)
.padding([3, 8]);
let qsel_icon = crate::ui::icons::semantic(ICON_QUICK_SELECT, 14.0);
let mut qsel_btn = button(qsel_icon).padding([3, 6]);
if is_objects {
sel_obj_btn = sel_obj_btn
.on_press(Message::WblockSelectObjects)
.style(button_style(false));
qsel_btn = qsel_btn
.on_press(Message::WblockQuickSelect)
.style(button_style(false));
} else {
sel_obj_btn = sel_obj_btn.style(button_style(false));
qsel_btn = qsel_btn.style(button_style(false));
}
let obj_btns_row = row![sel_obj_btn, qsel_btn]
.spacing(5)
.align_y(iced::Center);
let object_radios = column![
form_radio(
t!("Retain"),
WblockObjectMode::Retain,
if is_objects { Some(state.object_mode) } else { None },
Message::WblockObjectMode,
),
form_radio(
t!("Convert to block"),
WblockObjectMode::Convert,
if is_objects { Some(state.object_mode) } else { None },
Message::WblockObjectMode,
),
form_radio(
t!("Delete from drawing"),
WblockObjectMode::Delete,
if is_objects { Some(state.object_mode) } else { None },
Message::WblockObjectMode,
),
]
.spacing(3);
let count = state.selected_handles.len();
let status_row: Element<'a, Message> = if !is_objects {
Space::new().height(14).into()
} else 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().warning.base.color),
}),
]
.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_subgroup = group(
t!("Objects").into_owned(),
column![obj_btns_row, Space::new().height(2), object_radios, Space::new().height(2), status_row,]
.spacing(2),
Length::Fixed(142.0),
);
let sub_panels = row![base_point_group, objects_subgroup]
.spacing(8)
.width(Fill);
let source_group = group(
t!("Source").into_owned(),
column![
block_row,
entire_drawing_radio,
objects_radio,
Space::new().height(4),
sub_panels,
]
.spacing(4),
Length::Shrink,
);
// ── Destination Group ─────────────────────────────────────────────────────
let file_input = text_input("", &state.file_path)
.on_input(Message::WblockFilePath)
.on_submit(Message::WblockApply)
.style(field_style)
.size(11)
.padding([3, 6])
.width(Fill);
let browse_btn = button(
container(text("...").size(11))
.width(Fill)
.align_x(iced::alignment::Horizontal::Center),
)
.width(Length::Fixed(28.0))
.on_press(Message::WblockBrowsePath)
.style(button::secondary)
.padding([3, 0]);
let path_row = row![file_input, browse_btn].spacing(5).align_y(iced::Center);
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::WblockUnit(choice.code))
.text_size(11)
.padding([2, 5])
.width(Fill);
let units_row = row![
text(t!("Insert units:"))
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
Space::new().width(8),
unit_picker,
Space::new().width(Length::Fixed(33.0)),
]
.spacing(4)
.align_y(iced::Center);
let destination_group = group(
t!("Destination").into_owned(),
column![
text(t!("File name and path:"))
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
path_row,
Space::new().height(4),
units_row,
]
.spacing(3),
Length::Shrink,
);
// ── Footer Bar ────────────────────────────────────────────────────────────
let actions = row![
Space::new().width(Fill),
dialog_button(t!("OK"), Message::WblockApply, true),
dialog_button(t!("Cancel"), Message::CloseModal, false),
dialog_button(t!("Help"), Message::WblockHelp, false),
]
.spacing(8)
.align_y(iced::Center);
// ── Error Banner ──────────────────────────────────────────────────────────
let mut main_column = column![source_group, destination_group, actions,]
.spacing(8)
.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::WblockDismissError)
.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(6)
.width(sizing.width)
.padding(10);
}
main_column.into()
}
+414
View File
@@ -0,0 +1,414 @@
//! Attach External Reference dialog — the options chosen after picking a
//! drawing with ATTACH / XATTACH: reference type, scale, insertion point,
//! path type and rotation, with the file's preview and block unit.
use std::fmt;
use iced::widget::{
button, column, combo_box, container, image, pick_list, radio, row, text, text_input, Space,
};
use iced::{Element, Fill, Length, Theme};
use crate::app::Message;
use crate::io::xref_model::Pathtype;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
use crate::ui::window::block_definition::{group, labeled_checkbox};
/// One edit in the dialog.
#[derive(Debug, Clone)]
pub enum XrefAttachMsg {
/// Name box: a reference already in the drawing, picked or typed.
Name(String),
Browse,
Overlay(bool),
ScaleOnScreen(bool),
Scale(usize, String),
Uniform(bool),
InsertOnScreen(bool),
Insert(usize, String),
PathType(PathTypeChoice),
RotationOnScreen(bool),
Rotation(String),
Details(bool),
Apply,
Help,
}
/// Path type choice as the dialog lists it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathTypeChoice(pub Pathtype);
impl PathTypeChoice {
pub const ALL: [PathTypeChoice; 3] = [
PathTypeChoice(Pathtype::Full),
PathTypeChoice(Pathtype::Relative),
PathTypeChoice(Pathtype::None),
];
}
impl fmt::Display for PathTypeChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self.0 {
Pathtype::Full => t!("Full path"),
Pathtype::Relative => t!("Relative path"),
Pathtype::None => t!("No path"),
};
f.write_str(label.as_ref())
}
}
/// The dialog's working copy.
#[derive(Clone)]
pub struct XrefAttachState {
/// Absolute path of the drawing to attach.
pub path: String,
/// Reference name (the file name without extension).
pub name: String,
/// References already in the drawing: name and saved path.
pub existing: Vec<(String, String)>,
pub name_combo: combo_box::State<String>,
pub overlay: bool,
pub scale_on_screen: bool,
pub scale: [String; 3],
pub uniform: bool,
pub insert_on_screen: bool,
pub insert: [String; 3],
pub path_type: PathTypeChoice,
pub rotation_on_screen: bool,
pub rotation: String,
/// Block unit of the chosen drawing and its factor to the host's unit.
pub unit_label: String,
pub unit_factor: String,
pub found_in: String,
pub saved_path: String,
pub details: bool,
pub preview: Option<image::Handle>,
}
impl XrefAttachState {
pub fn new(existing: Vec<(String, String)>) -> Self {
let names = existing.iter().map(|(name, _)| name.clone()).collect();
Self {
path: String::new(),
name: String::new(),
existing,
name_combo: combo_box::State::new(names),
overlay: false,
scale_on_screen: false,
scale: ["1.0000".into(), "1.0000".into(), "1.0000".into()],
uniform: false,
insert_on_screen: true,
insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()],
path_type: PathTypeChoice(Pathtype::Relative),
rotation_on_screen: false,
rotation: "0".into(),
unit_label: String::new(),
unit_factor: String::new(),
found_in: String::new(),
saved_path: String::new(),
details: false,
preview: None,
}
}
/// Parsed X/Y/Z scale; a uniform scale takes X for all three.
pub fn scale_values(&self) -> Option<[f64; 3]> {
let parse = |s: &String| s.trim().parse::<f64>().ok();
let x = parse(&self.scale[0])?;
if self.uniform {
return Some([x, x, x]);
}
Some([x, parse(&self.scale[1])?, parse(&self.scale[2])?])
}
pub fn insert_point(&self) -> Option<glam::DVec3> {
let parse = |s: &String| s.trim().parse::<f64>().ok();
Some(glam::DVec3::new(
parse(&self.insert[0])?,
parse(&self.insert[1])?,
parse(&self.insert[2])?,
))
}
}
fn msg(m: XrefAttachMsg) -> Message {
Message::XrefAttach(m)
}
fn number_field<'a>(
label: &'a str,
value: &'a str,
enabled: bool,
on_input: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
let mut field = text_input("", value)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style);
if enabled {
field = field.on_input(on_input);
}
row![
text(label).size(11).style(muted_style).width(Length::Fixed(16.0)),
field
]
.spacing(6)
.align_y(iced::Center)
.into()
}
fn read_only_field<'a>(label: String, value: &'a str, label_width: f32) -> Element<'a, Message> {
row![
text(label)
.size(11)
.style(muted_style)
.width(Length::Fixed(label_width)),
text_input("", value)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
pub fn view_window<'a>(
state: &'a XrefAttachState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// ── Name ────────────────────────────────────────────────────────────
let name_combo = combo_box(
&state.name_combo,
"",
if state.name.is_empty() {
None
} else {
Some(&state.name)
},
|chosen: String| msg(XrefAttachMsg::Name(chosen)),
)
.size(11)
.padding([3, 6])
.width(Fill);
let name_section = column![
text(t!("Name:")).size(11).style(muted_style),
row![
name_combo,
button(text(t!("Browse...")).size(11))
.on_press(msg(XrefAttachMsg::Browse))
.style(button_style(false))
.padding([4, 12])
.width(Length::Fixed(96.0)),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(3);
// ── Preview + reference type ────────────────────────────────────────
let preview_body: Element<'a, Message> = match &state.preview {
Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(),
None => Space::new().width(Fill).height(Fill).into(),
};
let preview = group(
t!("Preview").into_owned(),
container(preview_body)
.width(Fill)
.height(Length::Fixed(132.0))
.style(|theme: &Theme| container::Style {
background: Some(iced::Background::Color(
theme.palette().background.base.color,
)),
border: iced::Border {
width: 1.0,
radius: 2.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
}),
Length::Shrink,
);
let reference_type = group(
t!("Reference Type").into_owned(),
column![
radio(
t!("Attachment"),
false,
Some(state.overlay),
|v| msg(XrefAttachMsg::Overlay(v))
)
.size(14)
.text_size(11),
radio(t!("Overlay"), true, Some(state.overlay), |v| msg(
XrefAttachMsg::Overlay(v)
))
.size(14)
.text_size(11),
]
.spacing(6),
Length::Shrink,
);
let left = column![preview, reference_type]
.spacing(6)
.width(Length::FillPortion(1));
// ── Scale + insertion point ─────────────────────────────────────────
let scale_enabled = !state.scale_on_screen;
let scale = group(
t!("Scale").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.scale_on_screen, |v| msg(
XrefAttachMsg::ScaleOnScreen(v)
)),
number_field("X:", &state.scale[0], scale_enabled, |v| msg(
XrefAttachMsg::Scale(0, v)
)),
number_field(
"Y:",
&state.scale[1],
scale_enabled && !state.uniform,
|v| msg(XrefAttachMsg::Scale(1, v))
),
number_field(
"Z:",
&state.scale[2],
scale_enabled && !state.uniform,
|v| msg(XrefAttachMsg::Scale(2, v))
),
labeled_checkbox(t!("Uniform Scale"), state.uniform, |v| msg(
XrefAttachMsg::Uniform(v)
)),
]
.spacing(5),
Length::Shrink,
);
let insert_enabled = !state.insert_on_screen;
let insertion = group(
t!("Insertion point").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.insert_on_screen, |v| msg(
XrefAttachMsg::InsertOnScreen(v)
)),
number_field("X:", &state.insert[0], insert_enabled, |v| msg(
XrefAttachMsg::Insert(0, v)
)),
number_field("Y:", &state.insert[1], insert_enabled, |v| msg(
XrefAttachMsg::Insert(1, v)
)),
number_field("Z:", &state.insert[2], insert_enabled, |v| msg(
XrefAttachMsg::Insert(2, v)
)),
]
.spacing(5),
Length::Shrink,
);
let middle = column![scale, insertion]
.spacing(6)
.width(Length::FillPortion(1));
// ── Path type, rotation, block unit ─────────────────────────────────
let path_type = group(
t!("Path type").into_owned(),
pick_list(Some(state.path_type), PathTypeChoice::ALL, |choice| choice.to_string())
.on_select(|v| msg(XrefAttachMsg::PathType(v)))
.text_size(11)
.padding([3, 6])
.width(Fill),
Length::Shrink,
);
let mut angle = text_input("", &state.rotation)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style);
if !state.rotation_on_screen {
angle = angle.on_input(|v| msg(XrefAttachMsg::Rotation(v)));
}
let rotation = group(
t!("Rotation").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.rotation_on_screen, |v| msg(
XrefAttachMsg::RotationOnScreen(v)
)),
row![
text(t!("Angle:"))
.size(11)
.style(muted_style)
.width(Length::Fixed(44.0)),
angle
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(5),
Length::Shrink,
);
let block_unit = group(
t!("Block Unit").into_owned(),
column![
read_only_field(t!("Unit:").into_owned(), &state.unit_label, 52.0),
read_only_field(t!("Factor:").into_owned(), &state.unit_factor, 52.0),
]
.spacing(5),
Length::Shrink,
);
let right = column![path_type, rotation, block_unit]
.spacing(6)
.width(Length::FillPortion(1));
let body = row![left, middle, right].spacing(6).width(Fill);
// ── Details ─────────────────────────────────────────────────────────
let details: Element<'a, Message> = if state.details {
group(
t!("Location").into_owned(),
column![
read_only_field(t!("Found in:").into_owned(), &state.found_in, 92.0),
read_only_field(t!("Saved path:").into_owned(), &state.saved_path, 92.0),
]
.spacing(5),
Length::Shrink,
)
} else {
Space::new().height(0).into()
};
// ── Footer ──────────────────────────────────────────────────────────
let details_label = if state.details {
t!("Hide Details")
} else {
t!("Show Details")
};
let footer = row![
button(text(details_label).size(11))
.on_press(msg(XrefAttachMsg::Details(!state.details)))
.style(button_style(false))
.padding([4, 12]),
Space::new().width(Fill),
button(text(t!("OK")).size(11))
.on_press(msg(XrefAttachMsg::Apply))
.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(msg(XrefAttachMsg::Help))
.style(button_style(false))
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
column![name_section, body, details, footer]
.spacing(6)
.padding([8, 10])
.width(sizing.width)
.into()
}