From 3c859c6b0eb403fc18db3ad16355bd20476fb04f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:45:39 +0000 Subject: [PATCH] Clean up Graphic Attributes palette - Revert formatting, import/module reordering and unrelated rewrites outside the feature (command.rs, style.rs, paper.rs, view/mod.rs, properties.rs, dialog.rs, ValuePromptCommand); the new SVG icons stay. - Move the palette logic out of dialog.rs into update/graphic_attributes.rs. - Replace 14 loose app fields and ~50 Message variants by GraphicAttributesState (one Menu enum for the open drop-down) and a nested GraphicAttributesMsg / GradientMsg, like BlockPaletteMsg. - Share the selection -> line/fill handles -> unlocked edit path, reuse valid_block_name, the ribbon lineweight handler and fill_handles, and drop the unused GradientColorToggle, color_open and LineLinetypeScaleInput. - Linetype scale accepts any positive value (e.g. ISO pen widths 0.13, 0.18, 0.25, 0.35) instead of rounding to 0.1 with a 0.1 minimum. - Closing open boundaries and creating the fill is one undo step. - The close prompt also answers typed Y/N + Enter, a bare Enter (Yes) and Escape (cancel), not only the option buttons. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu --- src/app/mod.rs | 93 +- src/app/update/command.rs | 680 +++++---- src/app/update/dialog.rs | 2018 +++----------------------- src/app/update/graphic_attributes.rs | 1693 +++++++++++++++++++++ src/app/update/mod.rs | 578 +------- src/app/update/style.rs | 68 +- src/app/view/mod.rs | 57 +- src/command.rs | 31 +- src/scene/entity.rs | 34 +- src/scene/hatch_boundary/tests.rs | 3 +- src/scene/model/hatch_model.rs | 5 +- src/scene/paper.rs | 42 +- src/ui/dock.rs | 7 +- src/ui/properties.rs | 139 +- src/ui/window/gradient_editor.rs | 113 +- src/ui/window/graphic_attributes.rs | 1877 ++++++++++++------------ src/ui/window/mod.rs | 32 +- 17 files changed, 3478 insertions(+), 3992 deletions(-) create mode 100644 src/app/update/graphic_attributes.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 713ec9d67..16a119f87 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,18 +12,20 @@ mod command_driver; pub(crate) mod commands; pub(crate) mod dim_viewport; #[cfg(test)] +mod viewport_dimension_tests; +#[cfg(test)] mod dimension_preview_tests; mod document; mod drafting_settings; pub(crate) mod expr_eval; +mod options_session; mod find_replace; pub(crate) mod helpers; mod history; mod layers; mod model_ops; -mod mtext_editor; mod navigation; -mod options_session; +mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; mod presspull_ops; @@ -38,8 +40,6 @@ mod text_inline; mod tolerance_dialog; mod update; mod view; -#[cfg(test)] -mod viewport_dimension_tests; mod visibility; pub use style_ops::StyleKind; @@ -768,24 +768,9 @@ pub(super) struct OpenCADStudio { render_mode_preview: Option, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, + /// Whether the Graphic Attributes palette is shown (GRAPHICATTRIBUTES). show_graphic_attributes: bool, - graphic_attributes_header_menu_open: bool, - graphic_attribute_menu_open: bool, - line_color_menu_open: bool, - line_linetype_menu_open: bool, - line_lineweight_menu_open: bool, - line_transparency_menu_open: bool, - graphic_hatch_pattern_search: String, - graphic_hatch_pattern_focus: usize, - solid_fill_color_menu_open: bool, - fill_transparency_menu_open: bool, - gradient_editor: Option, - hatch_editor_handles: Option>, - pending_graphic_fill_close: Option<( - crate::ui::window::graphic_attributes::GraphicAttribute, - Vec, - Vec, - )>, + graphic_attributes: crate::ui::window::graphic_attributes::GraphicAttributesState, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Docked External References panel visibility (EXTERNALREFERENCES). @@ -2067,57 +2052,7 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { - ToggleGraphicAttributesHeaderMenu, - CloseGraphicAttributesHeaderMenu, - GraphicAttributesSetAllByLayer, - GraphicAttributesSetAllByBlock, - GraphicAttributesRemoveReferences, - GraphicAttributesCreateLayer, - ToggleGraphicAttributeDropdown, - CloseGraphicAttributeDropdown, - GraphicAttributeChanged(crate::ui::window::graphic_attributes::GraphicAttribute), - ToggleLineColorDropdown, - CloseLineColorDropdown, - LineColorChanged(AcadColor), - ToggleLineLinetypeMenu, - CloseLineLinetypeMenu, - LineLinetypeChanged(String), - ToggleLineLineweightMenu, - CloseLineLineweightMenu, - LineLineweightChanged(LineWeight), - LineLinetypeScaleChanged(f64), - LineLinetypeScaleInput(String), - ToggleLineTransparencyDropdown, - CloseLineTransparencyDropdown, - LineTransparencyChanged(u8), - LineTransparencyByLayer, - LineTransparencyByBlock, - ToggleSolidFillColorDropdown, - CloseSolidFillColorDropdown, - SolidFillColorChanged(AcadColor), - ToggleFillTransparencyDropdown, - CloseFillTransparencyDropdown, - FillTransparencyChanged(u8), - FillTransparencyByLayer, - FillTransparencyByBlock, - HatchEditorOpen, - HatchEditorClose, - GraphicHatchPatternSearchChanged(String), - GraphicHatchPatternFocus(usize), - GraphicHatchPatternChanged(String), - GraphicHatchPatternConfirm, - GradientEditorOpen, - GradientColorModeChanged(crate::ui::window::gradient_editor::GradientColorMode), - GradientColorToggle(u8), - GradientColorChanged(u8, AcadColor), - GradientShadeTintChanged(f32), - GradientTypeChanged(crate::scene::model::hatch_model::GradientKind), - GradientInvertToggled, - GradientAngleChanged(String), - GradientAngleReset, - GradientCenteredChanged(bool), - GradientApply, - GradientCancel, + GraphicAttributes(crate::ui::window::graphic_attributes::GraphicAttributesMsg), SpaceMouseWake, SpaceMouseFrame(iced::time::Instant), SpaceMouseFocus(iced::window::Id, bool), @@ -4170,19 +4105,7 @@ impl OpenCADStudio { render_mode_preview: None, show_properties: true, show_graphic_attributes: false, - graphic_attributes_header_menu_open: false, - graphic_attribute_menu_open: false, - line_color_menu_open: false, - line_linetype_menu_open: false, - line_lineweight_menu_open: false, - line_transparency_menu_open: false, - graphic_hatch_pattern_search: String::new(), - graphic_hatch_pattern_focus: 0, - solid_fill_color_menu_open: false, - fill_transparency_menu_open: false, - gradient_editor: None, - hatch_editor_handles: None, - pending_graphic_fill_close: None, + graphic_attributes: Default::default(), show_block_palette: false, show_external_references: false, show_browser: false, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 27a1090f0..6b13e7fa4 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -4,22 +4,23 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; -use crate::app::TextEntryMode; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; +use crate::app::TextEntryMode; use crate::modules::ModuleEvent; -#[cfg(not(target_arch = "wasm32"))] -use crate::plugin::v4_support; -use crate::scene::model::object::{GripApply, PropValue}; use crate::scene::pick::grip::{ find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::plugin::v4_support; +use crate::scene::model::object::{GripApply, PropValue}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; -use crate::ui::window::attribute_editor::{AttrRow, AttrTab}; use crate::ui::PropertiesPanel; +use crate::ui::window::attribute_editor::{AttrRow, AttrTab}; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; @@ -242,23 +243,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Live incremental search for INSERT/MINSERT (see CommandInput) let live = self.command_line.input.clone(); let i = self.active_tab; - let (should_update, opts, prompt) = - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live) { - (true, cmd.options(), cmd.prompt()) - } else { - (false, Vec::new(), String::new()) - } - } else { - (false, Vec::new(), String::new()) - }; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; if should_update { self.command_line.set_step_options(opts); - if let Some(last) = self.command_line.history.last_mut() { - if last.pinned { - last.text = prompt; - } - } + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } } } } @@ -300,23 +290,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { let live = self.command_line.input.clone(); let i = self.active_tab; - let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() - { - if cmd.on_live_input(&live) { - (true, cmd.options(), cmd.prompt()) - } else { - (false, Vec::new(), String::new()) - } - } else { - (false, Vec::new(), String::new()) - }; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; if should_update { self.command_line.set_step_options(opts); - if let Some(last) = self.command_line.history.last_mut() { - if last.pinned { - last.text = prompt; - } - } + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } } } self.focus_cmd_input() @@ -327,6 +306,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // dropdown so the dispatched command's new prompt is // immediately visible on the overlay. self.command_line.close_history(); + if self.awaiting_fill_close_answer() { + let input = std::mem::take(&mut self.command_line.input); + return self.on_graphic_fill_close_input(&input); + } // A leading `>` was only a "literal spaces" typing hint (see // CommandSpace) — drop it before the input is interpreted. // Free-form text prompts keep it: there it is content, not a @@ -345,7 +328,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::scene::model::object::GripMenuAction::Lengthen => { Some(GripEditMode::Lengthen) } - crate::scene::model::object::GripMenuAction::Radius => Some(GripEditMode::Radius), + crate::scene::model::object::GripMenuAction::Radius => { + Some(GripEditMode::Radius) + } crate::scene::model::object::GripMenuAction::ArcLength => { Some(GripEditMode::ArcLength) } @@ -361,10 +346,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { _ => None, }; expected_mode.is_some_and(|mode| { - !self.tabs[self.active_tab] - .active_grip - .as_ref() - .is_some_and(|grip| { + !self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| { grip.mode == mode && grip.handle == pending.handle && grip.grip_id == pending.grip_id @@ -391,13 +373,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let raw = crate::app::expr_eval::eval_to_string(entered); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { - self.command_line.push_error( - crate::tf!("{}: expected a number, got \"{raw}\"", pending.label).as_ref(), - ); + self.command_line.push_error(crate::tf!( + "{}: expected a number, got \"{raw}\"", + pending.label + ).as_ref()); self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let interactive_value_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + let interactive_value_grip = self.tabs[i] + .active_grip + .as_ref() + .is_some_and(|grip| { matches!( (grip.mode, pending.action), ( @@ -419,7 +405,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { GripEditMode::MoveParallel, crate::scene::model::object::GripMenuAction::MoveParallel, ) - ) && grip.handle == pending.handle + ) + && grip.handle == pending.handle && grip.grip_id == pending.grip_id }); if interactive_value_grip { @@ -427,7 +414,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } use crate::entities::traits::EntityTypeOps; self.push_undo_snapshot(i, pending.label); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle) + { entity.apply_grip_menu_value(pending.grip_id, pending.action, v); } // A typed grip-menu value reshapes dimensions too — drop a @@ -435,9 +423,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i] .scene .invalidate_dim_block_recorded(pending.handle); - self.tabs[i] - .scene - .bump_entities(&[(pending.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[( + pending.handle, + crate::scene::ChangeKind::Modified, + )]); self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); @@ -454,25 +443,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; if let Some(grip) = self.tabs[i].active_grip.clone() { - if matches!( - grip.mode, - GripEditMode::Stretch | GripEditMode::RectangleResize - ) { + if matches!(grip.mode, GripEditMode::Stretch | GripEditMode::RectangleResize) { let dyn_locked = self.tabs[i] .dyn_fields .iter() .any(|field| field.buffer.is_some()); let target = if matches!(grip.mode, GripEditMode::RectangleResize) { - grip.rectangle_frame - .map(|(opposite, width_axis, height_axis)| { + grip.rectangle_frame.map(|(opposite, width_axis, height_axis)| { let cursor_delta = self.tabs[i].last_cursor_world - opposite; let mut width = cursor_delta.dot(width_axis); let mut height = cursor_delta.dot(height_axis); for field in &self.tabs[i].dyn_fields { - let value = field.buffer.as_ref().and_then(|buffer| { - crate::app::expr_eval::eval_number(buffer) - }); + let value = field + .buffer + .as_ref() + .and_then(|buffer| crate::app::expr_eval::eval_number(buffer)); if let Some(value) = value { match field.role { crate::command::DynRole::Width => { @@ -497,8 +483,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.dyn_resolve_point() } else { // Legacy command-line direct-distance entry. - let text = - crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + let text = crate::app::expr_eval::eval_to_string( + self.command_line.input.trim(), + ); crate::app::expr_eval::eval_number(text.trim()).map(|dist| { let cursor = self.tabs[i].last_cursor_world; @@ -563,9 +550,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } let delta = target - grip.last_world; - let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = - grip.rectangle_frame - { + let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame { let opposite_id = (grip.grip_id + 2) % 4; let mut edits = Vec::with_capacity(3); for adjacent_id in [(opposite_id + 1) % 4, (opposite_id + 3) % 4] { @@ -573,46 +558,44 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, candidate)| { - **owner == grip.handle && candidate.id == adjacent_id - }) + .find(|(owner, candidate)| **owner == grip.handle && candidate.id == adjacent_id) .map(|(_, candidate)| candidate.world) { let d = original - opposite; - let axis = - if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { + let axis = if d.dot(width_axis).abs() >= d.dot(height_axis).abs() { width_axis } else { height_axis }; - edits.push(( - grip.handle, - adjacent_id, - GripApply::Absolute( + edits.push((grip.handle, adjacent_id, GripApply::Absolute( opposite + axis * (target - opposite).dot(axis), - ), - )); + ))); } } edits.push((grip.handle, grip.grip_id, GripApply::Absolute(target))); edits } else { - grip.targets - .iter() - .map(|target_grip| { + grip.targets.iter().map(|target_grip| { let apply = if target_grip.is_translate { GripApply::Translate(delta) } else { - GripApply::Absolute(target_grip.last_world + delta) + GripApply::Absolute( + target_grip.last_world + delta, + ) }; - (target_grip.handle, target_grip.grip_id, apply) - }) - .collect() + ( + target_grip.handle, + target_grip.grip_id, + apply, + ) + }).collect() }; for (handle, grip_id, apply) in actions { - self.tabs[i].scene.apply_grip(handle, grip_id, apply); + self.tabs[i] + .scene + .apply_grip(handle, grip_id, apply); } self.solve_grip_constraints(i, &grip); @@ -922,10 +905,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { /// has an established anchor, and `text` parses as a scalar distance, /// project along the active reference ray (if any) or the current cursor /// direction in the active UCS plane. - pub(in crate::app) fn try_direct_distance_entry( - &mut self, - text: &str, - ) -> Option> { + pub(in crate::app) fn try_direct_distance_entry(&mut self, text: &str) -> Option> { let i = self.active_tab; if self.tabs[i] .active_cmd @@ -1031,13 +1011,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if !self.command_line.input.trim().is_empty() { return self.update(Message::CommandSubmit); } + if self.awaiting_fill_close_answer() { + return self.on_graphic_fill_close_input(""); + } // A grip edit is not an active CAD command, but its Dynamic Input // fields use the same keyboard path. Route Enter through CommandSubmit, // whose grip branch resolves Distance / Angle and finalizes the edit. let i = self.active_tab; let grip_dyn_locked = self.tabs[i].active_grip.is_some() && self.dyn_input - && self.tabs[i].dyn_fields.iter().any(|field| field.locked()); + && self.tabs[i] + .dyn_fields + .iter() + .any(|field| field.locked()); if grip_dyn_locked { return self.update(Message::CommandSubmit); @@ -1084,6 +1070,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } if self.ribbon.escape_extension() { return Task::none(); + } + if self.awaiting_fill_close_answer() { + self.graphic_attributes.pending_fill_close = None; + self.command_line.push_info(crate::t!("*Cancel*").as_ref()); + return Task::none(); } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. @@ -1147,8 +1138,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { sel.box_crossing_locked = false; } if was_pan { - self.command_line - .push_output(crate::t!("PAN ended.").as_ref()); + self.command_line.push_output(crate::t!("PAN ended.").as_ref()); } self.ribbon.deactivate_tool(); return Task::none(); @@ -1234,11 +1224,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } - pub(super) fn on_layer_toggle_vp_freeze( - &mut self, - layer_idx: usize, - vp_col_idx: usize, - ) -> Task { + pub(super) fn on_layer_toggle_vp_freeze(&mut self, layer_idx: usize, vp_col_idx: usize) -> Task { let i = self.active_tab; let vp_handle = self.tabs[i] .layers @@ -1331,7 +1317,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // added row is measured. match new_idx { Some(idx) => iced::widget::operation::scroll_to( - iced::advanced::widget::Id::new(crate::ui::window::layers::LAYER_TABLE_SCROLL_ID), + iced::advanced::widget::Id::new( + crate::ui::window::layers::LAYER_TABLE_SCROLL_ID, + ), iced::widget::scrollable::AbsoluteOffset { x: 0.0, y: idx as f32 * crate::ui::ROW_H, @@ -1350,18 +1338,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Layer "0" and the current layer can't be deleted — drop them // from the batch and note it. - let current = self.tabs[i] - .scene - .document - .header - .current_layer_name - .clone(); + let current = self.tabs[i].scene.document.header.current_layer_name.clone(); let before = names.len(); names.retain(|n| n != "0" && *n != current); if names.len() < before { - self.command_line.push_info( - crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref(), - ); + self.command_line + .push_info(crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref()); } if names.is_empty() { return Task::none(); @@ -1454,9 +1436,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { fn sync_layer_panel(&mut self, i: usize) { let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); + self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info); self.tabs[i].layers.selected = None; self.tabs[i].layers.selected_multi.clear(); self.sync_ribbon_layers(); @@ -1557,9 +1537,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { movement, ); self.tabs[i].dirty = true; - self.tabs[i] - .scene - .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[( + popup.handle, + crate::scene::ChangeKind::Modified, + )]); } } // Stretch / Move = grab this grip. Engage it so the next @@ -1578,7 +1559,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Some(acadrust::EntityType::MultiLeader(_)) ); let (grip_id, is_translate) = - if matches!(item.action, GripMenuAction::MoveWithLeader) && is_multileader { + if matches!(item.action, GripMenuAction::MoveWithLeader) + && is_multileader + { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { ( @@ -1598,9 +1581,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; let (frame, plane) = crate::entities::lwpolyline::rectangle_frame(polyline)?; - let opposite = polyline.vertices.get((popup.grip_id + 2) % 4)?.location; + let opposite = polyline + .vertices + .get((popup.grip_id + 2) % 4)? + .location; Some(( - glam::DVec3::from_array(plane.point_at([opposite.x, opposite.y])), + glam::DVec3::from_array( + plane.point_at([opposite.x, opposite.y]), + ), glam::DVec3::from_array(plane.vector_at(frame.width_axis)) .try_normalize()?, glam::DVec3::from_array(plane.vector_at(frame.height_axis)) @@ -1666,7 +1654,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip)| **owner == popup.handle && grip.id == popup.grip_id) + .find(|(owner, grip)| { + **owner == popup.handle && grip.id == popup.grip_id + }) { if self.grip_originals.is_empty() { self.grip_originals = self.tabs[i] @@ -1678,22 +1668,36 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .unwrap_or_default(); } self.tabs[i].active_grip = Some(match item.action { - GripMenuAction::Radius => { - GripEdit::radius(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::ArcLength => { - GripEdit::arc_length(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::RectangleWidth => { - GripEdit::rectangle_width(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::RectangleHeight => { - GripEdit::rectangle_height(popup.handle, popup.grip_id, grip.world) - } - GripMenuAction::MoveParallel => { - GripEdit::move_parallel(popup.handle, popup.grip_id, grip.world) - } - _ => GripEdit::lengthen(popup.handle, popup.grip_id, grip.world), + GripMenuAction::Radius => GripEdit::radius( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::ArcLength => GripEdit::arc_length( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::RectangleWidth => GripEdit::rectangle_width( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::RectangleHeight => GripEdit::rectangle_height( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::MoveParallel => GripEdit::move_parallel( + popup.handle, + popup.grip_id, + grip.world, + ), + _ => GripEdit::lengthen( + popup.handle, + popup.grip_id, + grip.world, + ), }); } // Popup actions do not pass through the normal grip @@ -1703,22 +1707,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // move (and so keyboard input has a field to target). self.sync_dyn_fields(); if matches!(item.action, GripMenuAction::Radius) { - self.command_line - .push_info(crate::t!("Specify point or enter radius:").as_ref()); + self.command_line.push_info( + crate::t!("Specify point or enter radius:").as_ref(), + ); } else if matches!(item.action, GripMenuAction::ArcLength) { - self.command_line - .push_info(crate::t!("Specify point or enter arc length:").as_ref()); + self.command_line.push_info( + crate::t!("Specify point or enter arc length:").as_ref(), + ); } else if matches!(item.action, GripMenuAction::RectangleWidth) { self.command_line.push_info("Specify point or enter width:"); } else if matches!(item.action, GripMenuAction::RectangleHeight) { - self.command_line - .push_info("Specify point or enter height:"); + self.command_line.push_info("Specify point or enter height:"); } else if matches!(item.action, GripMenuAction::MoveParallel) { - self.command_line - .push_info("Specify point or enter parallel offset:"); + self.command_line.push_info("Specify point or enter parallel offset:"); } else { - self.command_line - .push_info(crate::t!("Specify point or enter distance:").as_ref()); + self.command_line.push_info( + crate::t!("Specify point or enter distance:").as_ref(), + ); } } else { self.command_line.push_info(crate::tf!("{label}:").as_ref()); @@ -1744,8 +1749,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.refresh_selected_grips(); self.refresh_properties(); - self.command_line - .push_output(crate::t!("Polyline broken at vertex.").as_ref()); + self.command_line.push_output(crate::t!("Polyline broken at vertex.").as_ref()); } None => self .command_line @@ -1771,7 +1775,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }); if let Some((original, new_gid, old_len)) = placement { let dirty_before = self.tabs[i].dirty; - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(popup.handle) + { entity.apply_grip_menu(popup.grip_id, item.action); } let inserted = self.tabs[i] @@ -1781,17 +1787,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .and_then(polyline_vertex_count) .is_some_and(|len| len == old_len + 1); if !inserted { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(popup.handle) + { *entity = original; } - self.tabs[i] - .scene - .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[ + (popup.handle, crate::scene::ChangeKind::Modified), + ]); self.tabs[i].dirty = dirty_before; self.refresh_selected_grips(); self.refresh_properties(); - self.command_line - .push_error(crate::t!("Cannot add a vertex here.").as_ref()); + self.command_line.push_error(crate::t!("Cannot add a vertex here.").as_ref()); return Task::none(); } self.tabs[i] @@ -1804,22 +1811,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .selected_grip_handles .iter() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip)| **owner == popup.handle && grip.id == new_gid) + .find(|(owner, grip)| { + **owner == popup.handle && grip.id == new_gid + }) .map(|(_, grip)| grip.world); if let Some(grip_world) = grip_world { self.grip_originals = vec![(popup.handle, original)]; self.grip_dirty_before = Some(dirty_before); - self.tabs[i].active_grip = - Some(GripEdit::single(popup.handle, new_gid, false, grip_world)); + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + new_gid, + false, + grip_world, + )); self.command_line .push_info(crate::t!("Specify new vertex location:").as_ref()); } else { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) { + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(popup.handle) + { *entity = original; } - self.tabs[i] - .scene - .bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].scene.bump_entities(&[ + (popup.handle, crate::scene::ChangeKind::Modified), + ]); self.tabs[i].dirty = dirty_before; self.refresh_selected_grips(); self.refresh_properties(); @@ -1839,7 +1854,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { crate::entities::spline::shows_fit_points(spline) } - (GripMenuAction::ShowControlVertices, acadrust::EntityType::Spline(spline)) => { + ( + GripMenuAction::ShowControlVertices, + acadrust::EntityType::Spline(spline), + ) => { crate::entities::spline::shows_control_vertices(spline) } _ => false, @@ -1892,8 +1910,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .zip(self.tabs[i].selected_grips.iter()) .find(|(owner, g)| **owner == popup.handle && g.id == new_gid) { - self.tabs[i].active_grip = - Some(GripEdit::single(popup.handle, new_gid, false, g.world)); + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + new_gid, + false, + g.world, + )); self.grip_add_provisional = Some((popup.handle, new_gid)); } } @@ -1924,10 +1946,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // sync clipboard read returns nothing there); native uses // iced's clipboard. #[cfg(target_arch = "wasm32")] - return Task::perform(crate::sys::read_clipboard_text(), Message::MTextPasteClip); + return Task::perform( + crate::sys::read_clipboard_text(), + Message::MTextPasteClip, + ); #[cfg(not(target_arch = "wasm32"))] - return iced::clipboard::read_text() - .map(|result| Message::MTextPasteClip(result.ok().map(|text| (*text).clone()))); + return iced::clipboard::read_text().map(|result| { + Message::MTextPasteClip(result.ok().map(|text| (*text).clone())) + }); } if self.text_inline.is_some() { // Web: the iced text_input can't reach the async clipboard, @@ -1953,7 +1979,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { Task::perform(crate::sys::read_clipboard_text(), |text| { Message::SystemClipboardPaste(match text { - Some(text) if !text.is_empty() => crate::app::SystemClipboardText::Text(text), + Some(text) if !text.is_empty() => { + crate::app::SystemClipboardText::Text(text) + } _ => crate::app::SystemClipboardText::EmptyOrUnsupported, }) }) @@ -1983,11 +2011,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; let remembered = self.qselect_settings.clone(); - let scope = remembered - .as_ref() - .map_or(crate::app::QSelectScope::CurrentSpace, |settings| { - settings.scope - }); + let scope = remembered.as_ref().map_or( + crate::app::QSelectScope::CurrentSpace, + |settings| settings.scope, + ); let available_types = self.tabs[i].scene.qselect_entity_type_names(scope); let type_filter = if let Some(settings) = remembered.as_ref() { settings @@ -2021,12 +2048,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let mut operator = remembered .as_ref() .map_or(crate::app::QSelectOp::Eq, |settings| settings.operator); - if matches!( - operator, - crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt - ) && !property.as_ref().is_some_and(|property| { + if matches!(operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) + && !property.as_ref().is_some_and(|property| { matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) { + }) + { operator = crate::app::QSelectOp::Eq; } let value = remembered.as_ref().map_or_else(String::new, |settings| { @@ -2089,7 +2115,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // baked into the cached wire geometry — re-tessellate so the // change shows immediately (issue #231 class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); } }); @@ -2116,7 +2144,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.ribbon.active_color = color; } else { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_color(entity, color); } }); @@ -2153,7 +2183,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // re-tessellate so the dashed/solid look updates immediately // (issue #231 class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); } }); @@ -2162,11 +2194,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } - pub(super) fn on_ribbon_style_changed( - &mut self, - key: crate::modules::StyleKey, - name: String, - ) -> Task { + pub(super) fn on_ribbon_style_changed(&mut self, key: crate::modules::StyleKey, name: String) -> Task { use crate::modules::StyleKey; self.ribbon.close_dropdown(); match key { @@ -2236,7 +2264,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { use crate::scene::model::hatch_patterns; if let Some(entry) = hatch_patterns::find(&name) { self.push_undo_snapshot(i, "HATCHEDIT"); - for &handle in handles { + for &handle in handles { if let Some(acadrust::EntityType::Hatch(dxf)) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -2254,17 +2282,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { dxf.pattern_angle, ); let origin = dxf.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry( - &mut pattern, - origin.x, - origin.y, - ); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); dxf.pattern = pattern; dxf.is_solid = matches!( entry.gpu, crate::scene::model::hatch_model::HatchPattern::Solid ); - dxf.pattern_type = acadrust::entities::HatchPatternType::Predefined; + dxf.pattern_type = + acadrust::entities::HatchPatternType::Predefined; dxf.gradient_color.enabled = false; } if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) { @@ -2293,7 +2318,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // The Annotative Yes/No list drives the per-object annotative toggle // (MTEXT's own flag, the annotation context for the rest). if field == "annotative" { - let wanted = value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes"); + let wanted = + value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes"); let Some(handle) = handles.first().copied() else { return Task::none(); }; @@ -2418,15 +2444,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - let Some(mut state) = - self.tabs[i] + let Some(mut state) = self.tabs[i] .scene .document .get_entity(handle) .and_then(|entity| match entity { - acadrust::EntityType::Surface(surface) => { - Some(crate::entities::solid3d::surface_property_state(surface)) - } + acadrust::EntityType::Surface(surface) => Some( + crate::entities::solid3d::surface_property_state(surface), + ), _ => None, }) else { @@ -2448,9 +2473,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &mut self.tabs[i].scene.document, handle, crate::entities::solid3d::SURFACE_PROPERTIES_APP, - Some(crate::entities::solid3d::surface_property_xdata_values( - state, - )), + Some(crate::entities::solid3d::surface_property_xdata_values(state)), ); if field == PROP_SURFACE_WIREFRAME_TYPE { self.tabs[i].scene.reseed_derived_caches(handle); @@ -2641,16 +2664,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .then(|| { doc.objects.values().find_map(|object| match object { acadrust::objects::ObjectType::MultiLeaderStyle(style) - if style.name == value => - { - Some(style.clone()) - } + if style.name == value => Some(style.clone()), _ => None, }) }) .flatten(); let resolved: Option = match field { - "mleader_style" => resolved_mleader_style.as_ref().map(|style| style.handle), + "mleader_style" => { + resolved_mleader_style.as_ref().map(|style| style.handle) + } "text_style_handle" => doc .text_styles .iter() @@ -2787,7 +2809,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .document .block_records .iter() - .find(|b| crate::app::properties::arrowhead_label(&b.name) == value) + .find(|b| { + crate::app::properties::arrowhead_label(&b.name) == value + }) .map(|b| b.handle) } else { None @@ -2835,7 +2859,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Some(XDataValue::Handle(acadrust::Handle::NULL)), ); } else if let Some(h) = arrow_h { - dov::set(doc, handle, dov::DIMLDRBLK, Some(XDataValue::Handle(h))); + dov::set( + doc, + handle, + dov::DIMLDRBLK, + Some(XDataValue::Handle(h)), + ); } } _ => {} @@ -2868,7 +2897,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { if ins.block_name != canon { ins.block_name = canon.clone(); - changes.push((handle, crate::scene::ChangeKind::Modified)); + changes.push(( + handle, + crate::scene::ChangeKind::Modified, + )); } } } @@ -2877,8 +2909,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } else if field == "tbl_style_handle" { - let style_handle = - self.tabs[i] + let style_handle = self.tabs[i] .scene .document .objects @@ -2906,11 +2937,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { continue; } if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop( - entity, - "transparency", - &value, - ); + crate::scene::view::dispatch::apply_common_prop(entity, "transparency", &value); } } } else if field == "plot_style" { @@ -2921,19 +2948,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's // ACAD_PLOTSTYLENAME dictionary to its placeholder handle. - let dict_h = self.tabs[i] - .scene - .document - .header - .acad_plotstylename_dict_handle; + let dict_h = + self.tabs[i].scene.document.header.acad_plotstylename_dict_handle; let ph: Option = crate::scene::annotative::as_dict(&self.tabs[i].scene.document, dict_h) - .and_then(|d| d.entries.iter().find(|(n, _)| *n == value).map(|(_, h)| *h)); + .and_then(|d| { + d.entries + .iter() + .find(|(n, _)| *n == value) + .map(|(_, h)| *h) + }); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { let common = entity.common_mut(); match value.as_str() { "ByLayer" => { @@ -2967,14 +2997,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .objects .iter() .find_map(|(h, o)| match o { - acadrust::objects::ObjectType::Material(m) if m.name == value => Some(*h), + acadrust::objects::ObjectType::Material(m) if m.name == value => { + Some(*h) + } _ => None, }); for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { let common = entity.common_mut(); match value.as_str() { "ByLayer" => { @@ -3005,8 +3038,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::command::WorkingPlane::default() }; for &handle in &handles { - let mline_style = - self.tabs[i] + let mline_style = self.tabs[i] .scene .document .get_entity(handle) @@ -3027,13 +3059,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &value, ); } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( - entity, field, &value, plane, + entity, + field, + &value, + plane, ); if matches!(field, "ml_justification" | "ml_scale") { - if let (acadrust::EntityType::MLine(mline), Some(style)) = - (entity, mline_style.as_ref()) + if let ( + acadrust::EntityType::MLine(mline), + Some(style), + ) = (entity, mline_style.as_ref()) { crate::modules::draw::draw::mline::sync_mline_element_parameters( mline, style, @@ -3063,16 +3101,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { match field { "transparency" => { - let Some(transparency) = - crate::scene::creation_style::parse_current_transparency(&value) - else { - self.command_line.push_error( - "Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90.", - ); + let Some(transparency) = crate::scene::creation_style::parse_current_transparency(&value) else { + self.command_line.push_error("Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90."); self.refresh_properties(); return Task::none(); }; - if self.tabs[i].scene.document.current_entity_transparency() != transparency { + if self.tabs[i].scene.document.current_entity_transparency() + != transparency + { self.push_undo_snapshot(i, "CETRANSPARENCY"); if self.tabs[i] .scene @@ -3108,7 +3144,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } _ => { if let Some(h) = mat_handle { - self.tabs[i].scene.document.header.current_material_handle = h; + self.tabs[i].scene.document.header.current_material_handle = + h; } } } @@ -3121,8 +3158,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } match value.as_str() { - "ByBlock" => self.tabs[i].scene.document.header.current_plotstyle_type = 1, - "Normal" => self.tabs[i].scene.document.header.current_plotstyle_type = 2, + "ByBlock" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 1 + } + "Normal" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 2 + } _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, } self.tabs[i].dirty = true; @@ -3141,7 +3182,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .objects .values() .find_map(|object| { - let acadrust::objects::ObjectType::Layout(layout) = object else { + let acadrust::objects::ObjectType::Layout(layout) = object + else { return None; }; layout @@ -3149,7 +3191,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .eq_ignore_ascii_case(&layout_name) .then(|| layout.plot_style_sheet.clone()) }) - .unwrap_or_else(|| self.tabs[i].scene.document.header.stylesheet.clone()); + .unwrap_or_else(|| { + self.tabs[i].scene.document.header.stylesheet.clone() + }); if current != next { self.push_undo_snapshot(i, "PLOTSTYLE"); for object in self.tabs[i].scene.document.objects.values_mut() { @@ -3175,11 +3219,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .clone(); if !current.eq_ignore_ascii_case(&value) { self.push_undo_snapshot(i, "CANNOSCALE"); - if self.tabs[i] - .scene - .set_annotation_scale_named(&value) - .is_some() - { + if self.tabs[i].scene.set_annotation_scale_named(&value).is_some() { self.tabs[i].dirty = true; } else { self.discard_last_undo_entry(i); @@ -3356,7 +3396,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "Flat Shaded" => Some(Mode::FlatShaded), "Gouraud Shaded" => Some(Mode::GouraudShaded), "Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges), - "Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges), + "Gouraud Shaded + Edges" => { + Some(Mode::GouraudShadedWithEdges) + } _ => None, }; if let Some(mode) = mode { @@ -3394,8 +3436,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { "start_x" | "start_y" | "start_z" => Some(Some(0)), "end_x" | "end_y" | "end_z" => Some(Some(1)), "center_x" | "center_y" | "center_z" => Some(Some(-3)), - "radius" | "diameter" | "circumference" | "area" | "major_r" | "minor_r" - | "ratio" => Some(None), + "radius" | "diameter" | "circumference" | "area" + | "major_r" | "minor_r" | "ratio" => Some(None), _ => None, }; let retain_size = self.constraint_solve_mode @@ -3421,7 +3463,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .iter() .flat_map(|section| section.props.iter()) .find(|property| property.field == field) - .is_some_and(|property| matches!(property.value, PropValue::EditText(_))); + .is_some_and(|property| { + matches!(property.value, PropValue::EditText(_)) + }); if let Some(raw_val) = self.tabs[i] .properties .edit_buf @@ -3441,15 +3485,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .filter_map(|handle| { let entity = self.tabs[i].scene.document.get_entity(*handle)?; match (field, entity) { - ("current_fit_point", acadrust::EntityType::Spline(spline)) => { - Some(spline.fit_points.len()) - } - ("current_control_point", acadrust::EntityType::Spline(spline)) => { - Some(crate::entities::spline::control_vertex_count(spline)) - } - ("pm_current_vertex", acadrust::EntityType::PolygonMesh(mesh)) => { - Some(mesh.vertices.len()) - } + ( + "current_fit_point", + acadrust::EntityType::Spline(spline), + ) => Some(spline.fit_points.len()), + ( + "current_control_point", + acadrust::EntityType::Spline(spline), + ) => Some( + crate::entities::spline::control_vertex_count(spline), + ), + ( + "pm_current_vertex", + acadrust::EntityType::PolygonMesh(mesh), + ) => Some(mesh.vertices.len()), _ => None, } }) @@ -3467,8 +3516,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); if field == "tbl_style_handle" { - let style_handle = - self.tabs[i] + let style_handle = self.tabs[i] .scene .document .objects @@ -3610,8 +3658,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ); if applied && field == "dim_text_inside" { if let Some(acadrust::EntityType::Dimension( - acadrust::entities::Dimension::LargeRadial(dimension), - )) = self.tabs[i].scene.document.get_entity_mut(handle) + acadrust::entities::Dimension::LargeRadial( + dimension, + ), + )) = self.tabs[i] + .scene + .document + .get_entity_mut(handle) { dimension.base.text_user_positioned = false; } @@ -3624,7 +3677,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let vals = if val.trim().is_empty() { None } else { - Some(vec![acadrust::xdata::XDataValue::String(val.clone())]) + Some(vec![acadrust::xdata::XDataValue::String( + val.clone(), + )]) }; crate::scene::view::dispatch::set_entity_xdata( &mut self.tabs[i].scene.document, @@ -3647,7 +3702,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; let trimmed = val.trim(); if trimmed.is_empty() { - dov::set(&mut self.tabs[i].scene.document, handle, code, None); + dov::set( + &mut self.tabs[i].scene.document, + handle, + code, + None, + ); } else if let Ok(n) = trimmed.parse::() { dov::set( &mut self.tabs[i].scene.document, @@ -3673,19 +3733,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } _ => { - if crate::scene::model::solid_history::is_primitive_property(field) - { - self.tabs[i] - .scene - .apply_solid_history_property(handle, field, &val); + if crate::scene::model::solid_history::is_primitive_property( + field, + ) { + self.tabs[i].scene.apply_solid_history_property( + handle, + field, + &val, + ); } else if self.tabs[i] .scene - .apply_solid_position_property(handle, field, &val, plane) + .apply_solid_position_property( + handle, + field, + &val, + plane, + ) .is_none() { - let mline_style = - self.tabs[i].scene.document.get_entity(handle).and_then( - |entity| match entity { + let mline_style = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { acadrust::EntityType::MLine(mline) => { crate::entities::mline::resolved_mline_style( mline, @@ -3694,8 +3764,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .cloned() } _ => None, - }, - ); + }); if matches!(field, "text_x" | "text_y") { crate::entities::dimension::materialize_large_radial_text_position( &mut self.tabs[i].scene.document, @@ -3703,8 +3772,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { &val, ); } - if let Some(entity) = - self.tabs[i].scene.document.get_entity_mut(handle) + if let Some(entity) = self.tabs[i] + .scene + .document + .get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop_in_working_plane( entity, @@ -3724,7 +3795,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - if matches!(field, "srf_u_isolines" | "srf_v_isolines") { + if matches!(field, "srf_u_isolines" | "srf_v_isolines") + { self.tabs[i].scene.reseed_derived_caches(handle); } } @@ -3751,7 +3823,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { ) } None => { - crate::scene::parametric_constraints::ParametricRef::whole(handle) + crate::scene::parametric_constraints::ParametricRef::whole( + handle, + ) } }) }) @@ -3787,12 +3861,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Existing target → swap the selected references to it. Anonymous, // xref and xref-dependent definitions are not valid targets. - let target = self.tabs[i] - .scene - .document - .block_records - .get(new) - .map(|br| { + let target = self.tabs[i].scene.document.block_records.get(new).map(|br| { ( br.name.clone(), br.is_anonymous() || br.flags.is_xref || br.name.contains('|'), @@ -3895,7 +3964,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .collect::>(); Ok((ins.block_name.clone(), rows)) } - Some(acadrust::EntityType::Insert(_)) => Err("ATTEDIT This block has no attributes."), + Some(acadrust::EntityType::Insert(_)) => { + Err("ATTEDIT This block has no attributes.") + } _ => Err("ATTEDIT Select a block with attributes."), }; match result { @@ -3949,12 +4020,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // The block's layer may have been locked while the editor was open — // refuse to write attributes to a locked-layer block. if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) { - self.command_line.push_info( - crate::tf!( + self.command_line.push_info(crate::tf!( "Object is on locked layer \"{layer}\" — unlock the layer to edit its attributes." - ) - .as_ref(), - ); + ).as_ref()); return Task::none(); } // Snapshot the working copy so the document can be mutated while the @@ -4138,13 +4206,7 @@ mod layer_rename_tests { let i = app.active_tab; let old_name = app.tabs[i].layers.edit_buf.clone(); rename_layer(&mut app, &old_name, "TEST"); - let handle = app.tabs[i] - .scene - .document - .layers - .get("TEST") - .unwrap() - .handle; + let handle = app.tabs[i].scene.document.layers.get("TEST").unwrap().handle; rename_layer(&mut app, "TEST", "test"); @@ -4172,10 +4234,7 @@ mod layer_rename_tests { fn current_layer_rename_updates_name_and_allocated_handle() { let mut app = app_with_editing_layer(); let i = app.active_tab; - let idx = app.tabs[i] - .layers - .editing - .expect("new layer is being edited"); + let idx = app.tabs[i].layers.editing.expect("new layer is being edited"); let old_name = app.tabs[i].layers.layers[idx].name.clone(); app.tabs[i] .scene @@ -4189,7 +4248,12 @@ mod layer_rename_tests { rename_layer(&mut app, &old_name, "Renamed"); - let layer = app.tabs[i].scene.document.layers.get("Renamed").unwrap(); + let layer = app.tabs[i] + .scene + .document + .layers + .get("Renamed") + .unwrap(); assert!(layer.handle.is_valid()); assert_eq!(app.tabs[i].active_layer, "Renamed"); assert_eq!(app.tabs[i].layers.current_layer, "Renamed"); @@ -4208,10 +4272,7 @@ mod layer_rename_tests { fn layer_rename_undo_redo_restores_active_layer() { let mut app = app_with_editing_layer(); let i = app.active_tab; - let idx = app.tabs[i] - .layers - .editing - .expect("new layer is being edited"); + let idx = app.tabs[i].layers.editing.expect("new layer is being edited"); let old_name = app.tabs[i].layers.layers[idx].name.clone(); app.tabs[i].layers.selected = Some(idx); let _ = app.on_layer_set_current(); @@ -4237,10 +4298,7 @@ mod layer_rename_tests { let old_name = app.tabs[i].layers.edit_buf.clone(); rename_layer(&mut app, &old_name, "A"); let _ = app.on_layer_new(); - let b_idx = app.tabs[i] - .layers - .editing - .expect("new layer is being edited"); + let b_idx = app.tabs[i].layers.editing.expect("new layer is being edited"); app.tabs[i].layers.selected = Some(b_idx); let _ = app.on_layer_set_current(); let header_name = app.tabs[i].scene.document.header.current_layer_name.clone(); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 542370d3e..29d9c111a 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,1011 +2,27 @@ #![allow(unused_imports)] use super::util::*; +use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::model::object::GripApply; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; +use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; -use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; -fn set_hatch_graphic_attribute( - hatch: &mut acadrust::entities::Hatch, - value: crate::ui::window::graphic_attributes::GraphicAttribute, - visible_color: [f32; 4], -) { - use crate::ui::window::graphic_attributes::GraphicAttribute; - match value { - GraphicAttribute::Solid => { - hatch.pattern = acadrust::entities::HatchPattern::new("SOLID"); - hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; - hatch.is_solid = true; - hatch.gradient_color.enabled = false; - } - GraphicAttribute::Hatch => { - let Some(entry) = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| { - matches!( - entry.gpu, - crate::scene::model::hatch_model::HatchPattern::Pattern(_) - ) - }) - else { - return; - }; - let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); - crate::entities::hatch::scale_pattern_geometry( - &mut pattern, - hatch.pattern_scale.max(1.0e-6), - ); - crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); - let origin = hatch.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); - hatch.pattern = pattern; - hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; - hatch.is_solid = false; - hatch.gradient_color.enabled = false; - } - GraphicAttribute::Gradient => { - let to_color = |rgba: [f32; 4]| acadrust::types::Color::Rgb { - r: (rgba[0] * 255.0).round().clamp(0.0, 255.0) as u8, - g: (rgba[1] * 255.0).round().clamp(0.0, 255.0) as u8, - b: (rgba[2] * 255.0).round().clamp(0.0, 255.0) as u8, - }; - hatch.is_solid = true; - hatch.gradient_color.enabled = true; - hatch.gradient_color.name = "LINEAR".into(); - hatch.gradient_color.angle = 0.0; - hatch.pattern_angle = 0.0; - hatch.gradient_color.shift = 0.0; - hatch.gradient_color.is_single_color = false; - hatch.gradient_color.colors = vec![ - acadrust::entities::hatch::GradientColorEntry { - value: 0.0, - color: to_color(visible_color), - }, - acadrust::entities::hatch::GradientColorEntry { - value: 1.0, - color: acadrust::types::Color::Rgb { - r: 46, - g: 46, - b: 46, - }, - }, - ]; - } - GraphicAttribute::None | GraphicAttribute::Varies => {} - } -} - -fn is_open_fill_boundary(entity: &acadrust::EntityType) -> bool { - match entity { - acadrust::EntityType::LwPolyline(polyline) => { - !polyline.is_closed && polyline.vertices.len() >= 3 - } - acadrust::EntityType::Polyline(polyline) => { - !polyline.flags.is_closed() && polyline.vertices.len() >= 3 - } - acadrust::EntityType::Polyline2D(polyline) => { - !polyline.is_closed() && polyline.vertices.len() >= 3 - } - acadrust::EntityType::Polyline3D(polyline) => { - !polyline.flags.closed && polyline.vertices.len() >= 3 - } - _ => false, - } -} - -fn close_fill_boundary(entity: &mut acadrust::EntityType) -> bool { - match entity { - acadrust::EntityType::LwPolyline(polyline) if !polyline.is_closed => { - polyline.is_closed = true; - true - } - acadrust::EntityType::Polyline(polyline) if !polyline.flags.is_closed() => { - polyline.flags.set_closed(true); - true - } - acadrust::EntityType::Polyline2D(polyline) if !polyline.is_closed() => { - polyline.flags.set_closed(true); - true - } - acadrust::EntityType::Polyline3D(polyline) if !polyline.flags.closed => { - polyline.flags.closed = true; - true - } - _ => false, - } -} - -struct ConcreteGraphicAttributes { - color: acadrust::types::Color, - linetype: String, - lineweight: acadrust::types::LineWeight, - transparency: acadrust::types::Transparency, -} - -fn concrete_graphic_attributes( - color: acadrust::types::Color, - linetype: &str, - lineweight: acadrust::types::LineWeight, - transparency: acadrust::types::Transparency, - layer: Option<&acadrust::tables::Layer>, -) -> ConcreteGraphicAttributes { - let layer_color = layer.map_or(acadrust::types::Color::Index(7), |layer| { - match layer.color { - acadrust::types::Color::ByLayer | acadrust::types::Color::ByBlock => { - acadrust::types::Color::Index(7) - } - color => color, - } - }); - let layer_linetype = layer - .map(|layer| layer.line_type.as_str()) - .filter(|value| { - !value.is_empty() - && !value.eq_ignore_ascii_case("ByLayer") - && !value.eq_ignore_ascii_case("ByBlock") - }) - .unwrap_or("Continuous"); - let layer_lineweight = layer.map_or(acadrust::types::LineWeight::Default, |layer| { - match layer.line_weight { - acadrust::types::LineWeight::ByLayer | acadrust::types::LineWeight::ByBlock => { - acadrust::types::LineWeight::Default - } - value => value, - } - }); - let layer_transparency = layer.map_or_else( - || acadrust::types::Transparency::from_percent(0.0), - |layer| { - if layer.transparency.is_by_layer() || layer.transparency.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - layer.transparency - } - }, - ); - - ConcreteGraphicAttributes { - color: match color { - acadrust::types::Color::ByLayer => layer_color, - acadrust::types::Color::ByBlock => acadrust::types::Color::Index(7), - value => value, - }, - linetype: if linetype.is_empty() || linetype.eq_ignore_ascii_case("ByLayer") { - layer_linetype.to_owned() - } else if linetype.eq_ignore_ascii_case("ByBlock") { - "Continuous".to_owned() - } else { - linetype.to_owned() - }, - lineweight: match lineweight { - acadrust::types::LineWeight::ByLayer => layer_lineweight, - acadrust::types::LineWeight::ByBlock => acadrust::types::LineWeight::Default, - value => value, - }, - transparency: if transparency.is_by_layer() { - layer_transparency - } else if transparency.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - transparency - }, - } -} - -fn valid_layer_name(name: &str) -> bool { - !name.is_empty() - && name.chars().count() <= 255 - && !name.chars().any(|character| { - character.is_control() - || matches!( - character, - '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`' - ) - }) -} impl OpenCADStudio { - fn graphic_attribute_handles(&self, tab: usize) -> Vec { - let selected = self.tabs[tab].scene.selected_handles_in_order(); - let document = &self.tabs[tab].scene.document; - let mut seen = rustc_hash::FxHashSet::default(); - crate::ui::window::graphic_attributes::line_handles(document, &selected) - .into_iter() - .chain(crate::ui::window::graphic_attributes::fill_handles( - document, &selected, - )) - .filter(|handle| seen.insert(*handle)) - .collect() - } - - fn current_graphic_attributes(&self, tab: usize) -> ConcreteGraphicAttributes { - let document = &self.tabs[tab].scene.document; - let handles = self.graphic_attribute_handles(tab); - let draw_depth = self.tabs[tab].scene.draw_depth_map(); - let entity = handles - .iter() - .filter_map(|handle| { - document.get_entity(*handle).map(|entity| { - let depth = draw_depth - .get(&handle.value()) - .map_or(0.0, |value| value[0]); - (depth, entity) - }) - }) - .max_by(|(left, _), (right, _)| left.total_cmp(right)) - .map(|(_, entity)| entity); - - if let Some(entity) = entity { - let common = entity.common(); - return concrete_graphic_attributes( - common.color, - &common.linetype, - common.line_weight, - common.transparency, - document.layers.get(&common.layer), - ); - } - - let header = &document.header; - let layer_name = if header.current_layer_name.is_empty() { - self.tabs[tab].active_layer.as_str() - } else { - header.current_layer_name.as_str() - }; - concrete_graphic_attributes( - header.current_entity_color, - &header.current_linetype_name, - acadrust::types::LineWeight::from_value(header.current_line_weight), - document.current_entity_transparency(), - document.layers.get(layer_name), - ) - } - - pub(super) fn on_graphic_attributes_set_all(&mut self, by_layer: bool) -> iced::Task { - self.graphic_attributes_header_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let (color, linetype, lineweight, transparency) = if by_layer { - ( - acadrust::types::Color::ByLayer, - "ByLayer", - acadrust::types::LineWeight::ByLayer, - acadrust::types::Transparency::BY_LAYER, - ) - } else { - ( - acadrust::types::Color::ByBlock, - "ByBlock", - acadrust::types::LineWeight::ByBlock, - acadrust::types::Transparency::BY_BLOCK, - ) - }; - if selected.is_empty() { - let header = &mut self.tabs[i].scene.document.header; - header.current_entity_color = color; - header.current_linetype_name = linetype.to_owned(); - header.current_linetype_handle = acadrust::Handle::NULL; - header.current_line_weight = lineweight.value(); - self.tabs[i] - .scene - .document - .set_current_entity_transparency(transparency); - self.tabs[i].dirty = true; - self.sync_ribbon_from_selection(); - self.refresh_properties(); - return iced::Task::none(); - } - - let handles: Vec<_> = self - .graphic_attribute_handles(i) - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); - crate::scene::view::dispatch::apply_line_weight(entity, lineweight); - entity.common_mut().transparency = transparency; - } - }); - iced::Task::none() - } - - pub(super) fn on_graphic_attributes_remove_references(&mut self) -> iced::Task { - self.graphic_attributes_header_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - let values = self.current_graphic_attributes(i); - let linetype_handle = self.tabs[i] - .scene - .document - .line_types - .iter() - .find(|linetype| linetype.name.eq_ignore_ascii_case(&values.linetype)) - .map_or(acadrust::Handle::NULL, |linetype| linetype.handle); - let header = &mut self.tabs[i].scene.document.header; - header.current_entity_color = values.color; - header.current_linetype_name = values.linetype; - header.current_linetype_handle = linetype_handle; - header.current_line_weight = values.lineweight.value(); - self.tabs[i] - .scene - .document - .set_current_entity_transparency(values.transparency); - self.tabs[i].dirty = true; - self.sync_ribbon_from_selection(); - self.refresh_properties(); - return iced::Task::none(); - } - - let handles: Vec<_> = self - .graphic_attribute_handles(i) - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - let values: rustc_hash::FxHashMap<_, _> = handles - .iter() - .filter_map(|handle| { - let entity = self.tabs[i].scene.document.get_entity(*handle)?; - let common = entity.common(); - Some(( - *handle, - concrete_graphic_attributes( - common.color, - &common.linetype, - common.line_weight, - common.transparency, - self.tabs[i].scene.document.layers.get(&common.layer), - ), - )) - }) - .collect(); - self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { - let Some(values) = values.get(&handle) else { - return; - }; - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, values.color); - crate::scene::view::dispatch::apply_common_prop( - entity, - "linetype", - &values.linetype, - ); - crate::scene::view::dispatch::apply_line_weight(entity, values.lineweight); - entity.common_mut().transparency = values.transparency; - } - }); - iced::Task::none() - } - - pub(crate) fn create_graphic_attributes_layer( - &mut self, - raw_name: &str, - ) -> iced::Task { - let i = self.active_tab; - let name = raw_name.trim(); - if !valid_layer_name(name) { - self.command_line - .push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|=`."); - return iced::Task::none(); - } - if self.tabs[i] - .scene - .document - .layers - .iter() - .any(|layer| layer.name.eq_ignore_ascii_case(name)) - { - self.command_line.push_error( - crate::tf!("Graphic Attributes: layer \"{name}\" already exists.").as_ref(), - ); - return iced::Task::none(); - } - - let values = self.current_graphic_attributes(i); - let layer_name = name.to_owned(); - let undo = self.begin_layer_undo( - i, - "Create layer with active settings", - std::slice::from_ref(&layer_name), - ); - let mut layer = acadrust::tables::Layer::new(name); - layer.handle = self.tabs[i].scene.document.allocate_handle(); - layer.color = values.color; - layer.line_type = values.linetype; - layer.line_weight = values.lineweight; - layer.transparency = values.transparency; - use acadrust::tables::Table; - if self.tabs[i].scene.document.layers.add(layer).is_err() { - self.command_line - .push_error("Graphic Attributes: the layer could not be created."); - return iced::Task::none(); - } - self.tabs[i].dirty = true; - self.commit_layer_undo(i, undo); - self.refresh_layer_panel(); - self.command_line.push_output( - crate::tf!("Created layer \"{name}\" with the active Graphic Attributes settings.") - .as_ref(), - ); - iced::Task::none() - } - - pub(super) fn open_gradient_editor(&mut self) { - use crate::ui::window::gradient_editor::GradientEditorState; - let i = self.active_tab; - let selected: rustc_hash::FxHashSet<_> = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - let Some(hatch) = self.tabs[i].scene.document.entities().find_map(|entity| { - let acadrust::EntityType::Hatch(hatch) = entity else { - return None; - }; - if !hatch.gradient_color.enabled { - return None; - } - (selected.contains(&entity.common().handle) - || hatch - .paths - .iter() - .flat_map(|p| p.boundary_handles.iter()) - .any(|handle| selected.contains(handle))) - .then_some(hatch) - }) else { - return; - }; - self.gradient_editor = Some(GradientEditorState::from_hatch( - selected.iter().copied().collect(), - hatch, - )); - } - - pub(super) fn apply_gradient_editor(&mut self) -> iced::Task { - use crate::ui::window::gradient_editor::GradientColorMode; - let Some(state) = self.gradient_editor.take() else { - return iced::Task::none(); - }; - let i = self.active_tab; - let sources: rustc_hash::FxHashSet<_> = state.handles.iter().copied().collect(); - let targets: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let acadrust::EntityType::Hatch(h) = entity else { - return None; - }; - (h.gradient_color.enabled - && (sources.contains(&entity.common().handle) - || h.paths - .iter() - .any(|p| p.boundary_handles.iter().any(|x| sources.contains(x))))) - .then_some(entity.common().handle) - }) - .collect(); - if targets.is_empty() { - return iced::Task::none(); - } - if targets - .iter() - .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) - { - self.command_line - .push_error("Gradient Fill: the fill is on a locked layer."); - self.gradient_editor = Some(state); - return iced::Task::none(); - } - let angle = state.angle_radians(); - let pending = self.begin_undo(i, "Edit gradient", targets.len(), true); - for handle in &targets { - let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { - continue; - }; - if let acadrust::EntityType::Hatch(h) = &mut entity { - h.gradient_color.name = state.kind.dxf_name(state.inverted).into(); - h.gradient_color.angle = angle; - h.pattern_angle = angle; - h.gradient_color.shift = if state.centered { 0.0 } else { 1.0 }; - h.gradient_color.is_single_color = state.color_mode == GradientColorMode::One; - h.gradient_color.color_tint = state.shade_tint as f64; - h.gradient_color.colors = vec![ - acadrust::entities::hatch::GradientColorEntry { - value: 0.0, - color: state.color_1.clone(), - }, - acadrust::entities::hatch::GradientColorEntry { - value: 1.0, - color: state.color_2.clone(), - }, - ]; - } - self.tabs[i].scene.update_entity(entity); - } - self.tabs[i].dirty = true; - self.refresh_properties(); - if let Some(p) = pending { - self.commit_undo_delta(i, p) - } - iced::Task::none() - } - - pub(super) fn on_graphic_attribute( - &mut self, - value: crate::ui::window::graphic_attributes::GraphicAttribute, - ) -> iced::Task { - self.pending_graphic_fill_close = None; - self.on_graphic_attribute_impl(value, true) - } - - pub(super) fn on_graphic_fill_close_response( - &mut self, - close: bool, - ) -> iced::Task { - let Some((value, handles, _)) = self.pending_graphic_fill_close.take() else { - return iced::Task::none(); - }; - if close { - let i = self.active_tab; - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - if !unlocked.is_empty() { - let pending = self.begin_undo(i, "Close fill boundaries", unlocked.len(), true); - for handle in unlocked { - let Some(mut entity) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { - continue; - }; - if close_fill_boundary(&mut entity) { - self.tabs[i].scene.update_entity(entity); - } - } - self.tabs[i].dirty = true; - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - } - } - self.on_graphic_attribute_impl(value, false) - } - - fn on_graphic_attribute_impl( - &mut self, - value: crate::ui::window::graphic_attributes::GraphicAttribute, - ask_to_close: bool, - ) -> iced::Task { - use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; - use crate::ui::window::graphic_attributes::GraphicAttribute; - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - self.gradient_editor = None; - self.hatch_editor_handles = None; - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = 0; - if value == GraphicAttribute::Varies { - return iced::Task::none(); - } - let i = self.active_tab; - let selected: Vec<_> = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - if selected.is_empty() { - self.command_line - .push_info(crate::t!("Select one or more closed objects first.").as_ref()); - return iced::Task::none(); - } - let open_boundaries: rustc_hash::FxHashSet<_> = selected - .iter() - .copied() - .filter(|handle| { - self.tabs[i] - .scene - .document - .get_entity(*handle) - .is_some_and(is_open_fill_boundary) - }) - .collect(); - if ask_to_close && value != GraphicAttribute::None && !open_boundaries.is_empty() { - self.pending_graphic_fill_close = - Some((value, open_boundaries.iter().copied().collect(), selected.clone())); - return iced::Task::none(); - } - let selected_handles: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); - let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None - { - let associated: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let acadrust::EntityType::Hatch(hatch) = entity else { - return None; - }; - let directly_selected = selected_handles.contains(&entity.common().handle); - let selected_boundary = hatch.is_associative - && hatch.paths.iter().any(|path| { - path.boundary_handles - .iter() - .any(|handle| selected_handles.contains(handle)) - }); - (directly_selected || selected_boundary).then_some(entity.common().handle) - }) - .collect(); - (associated, Vec::new(), Vec::new(), 0) - } else { - let working = if self.tabs[i].editing_model_space() { - self.tabs[i].ucs_xform().working_plane() - } else { - crate::command::WorkingPlane::default() - }; - let normal = working.z.normalize_or(glam::DVec3::Z); - let storage = crate::entities::curve::ocs_plane( - acadrust::types::Vector3::new(normal.x, normal.y, normal.z), - working.origin.dot(normal), - ); - let plane = crate::command::WorkingPlane::new( - glam::DVec3::from_array(storage.origin), - glam::DVec3::from_array(storage.x_axis), - glam::DVec3::from_array(storage.y_axis), - ); - let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); - let (name, pattern) = match value { - GraphicAttribute::Solid => ("SOLID".into(), HatchPattern::Solid), - GraphicAttribute::Hatch => { - let Some(entry) = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| matches!(entry.gpu, HatchPattern::Pattern(_))) - else { - self.command_line - .push_error("Graphic Attributes: no hatch pattern is available."); - return iced::Task::none(); - }; - (entry.name.clone(), entry.gpu.clone()) - } - GraphicAttribute::Gradient => ( - "LINEAR".into(), - HatchPattern::Gradient { - angle_deg: 0.0, - color2: [0.18, 0.18, 0.18, 0.0], - kind: GradientKind::Linear, - invert: false, - shift: 0.0, - }, - ), - GraphicAttribute::None | GraphicAttribute::Varies => unreachable!(), - }; - let origin = self.tabs[i].scene.document.hatch_origin(); - let mut hatches = Vec::new(); - let mut updated_hatches = Vec::new(); - let mut directly_updated = rustc_hash::FxHashSet::default(); - let mut changed_boundaries = rustc_hash::FxHashSet::default(); - let mut skipped = 0; - for handle in &selected { - if let Some(acadrust::EntityType::Hatch(hatch)) = - self.tabs[i].scene.document.get_entity(*handle) - { - let mut hatch = hatch.clone(); - let visible_color = - crate::scene::view::render::render_style_for_common_viewport( - &self.tabs[i].scene.document, - &hatch.common, - None, - ) - .0; - set_hatch_graphic_attribute(&mut hatch, value, visible_color); - updated_hatches.push(acadrust::EntityType::Hatch(hatch)); - directly_updated.insert(*handle); - continue; - } - // Object mode only needs this object's source. Including all - // scene sources can associate coincident, unselected objects. - let object_sources = sources - .get(handle) - .map(|source| (*handle, source.clone())) - .into_iter() - .collect(); - let command = crate::modules::draw::draw::hatch::HatchCommand::new( - Vec::new(), - object_sources, - vec![*handle], - None, - plane, - ) - .with_origin(origin) - .with_pattern(name.clone(), pattern.clone()); - if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { - hatches.push(hatch); - changed_boundaries.insert(*handle); - } else { - if !open_boundaries.contains(handle) { - skipped += 1; - } - } - } - // Replace only fills belonging to boundaries that successfully - // produced a new hatch. Existing fills on skipped objects remain. - let associated: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let acadrust::EntityType::Hatch(hatch) = entity else { - return None; - }; - (hatch.is_associative - && !directly_updated.contains(&entity.common().handle) - && hatch.paths.iter().any(|path| { - path.boundary_handles - .iter() - .any(|handle| changed_boundaries.contains(handle)) - })) - .then_some(entity.common().handle) - }) - .collect(); - (associated, hatches, updated_hatches, skipped) - }; - if associated - .iter() - .chain(updated_hatches.iter().map(|entity| &entity.common().handle)) - .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) - { - self.command_line - .push_error("Graphic Attributes: an associated fill is on a locked layer."); - return iced::Task::none(); - } - if skipped > 0 { - let message = if skipped == 1 { - "Graphic Attributes: 1 unsupported object was not changed.".to_owned() - } else { - format!( - "Graphic Attributes: {skipped} unsupported objects were not changed." - ) - }; - self.command_line.push_info(&message); - } - if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { - return iced::Task::none(); - } - let pending = self.begin_undo( - i, - "Graphic Attributes", - associated.len() + new_hatches.len() + updated_hatches.len(), - true, - ); - self.tabs[i].scene.erase_entities(&associated); - for entity in updated_hatches { - self.tabs[i].scene.update_entity(entity); - } - let layer = self.tabs[i].active_layer.clone(); - for hatch in new_hatches { - self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); - } - self.tabs[i].dirty = true; - self.refresh_properties(); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); - } - iced::Task::none() - } - - pub(super) fn on_solid_fill_color( - &mut self, - color: acadrust::types::Color, - ) -> iced::Task { - self.solid_fill_color_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Fill color", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_color(&mut self, color: acadrust::types::Color) -> iced::Task { - self.line_color_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - return self.on_ribbon_color_changed(color); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line color", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_color(entity, color); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_linetype(&mut self, linetype: String) -> iced::Task { - self.line_linetype_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - return self.on_ribbon_linetype_changed(linetype); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line linetype", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_lineweight( - &mut self, - lineweight: acadrust::types::LineWeight, - ) -> iced::Task { - self.line_lineweight_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - self.tabs[i].scene.document.header.current_line_weight = lineweight.value(); - self.tabs[i].dirty = true; - self.ribbon.active_lineweight = lineweight; - self.refresh_properties(); - return iced::Task::none(); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line weight", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_line_weight(entity, lineweight); - } - }); - iced::Task::none() - } - - pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> iced::Task { - let Some(scale) = crate::ui::window::graphic_attributes::normalized_linetype_scale(scale) - else { - return iced::Task::none(); - }; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - self.tabs[i] - .scene - .document - .header - .current_entity_linetype_scale = scale; - self.tabs[i].dirty = true; - self.refresh_properties(); - return iced::Task::none(); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Linetype scale", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - entity.common_mut().linetype_scale = scale; - } - }); - iced::Task::none() - } - - pub(super) fn on_line_transparency( - &mut self, - transparency: acadrust::types::Transparency, - ) -> iced::Task { - self.line_transparency_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - if selected.is_empty() { - if self.tabs[i] - .scene - .document - .set_current_entity_transparency(transparency) - { - self.tabs[i].dirty = true; - self.refresh_properties(); - } - return iced::Task::none(); - } - let handles = crate::ui::window::graphic_attributes::line_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Line transparency", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - entity.common_mut().transparency = transparency; - } - }); - iced::Task::none() - } - - pub(super) fn on_fill_transparency( - &mut self, - transparency: acadrust::types::Transparency, - ) -> iced::Task { - self.fill_transparency_menu_open = false; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - let unlocked: Vec<_> = handles - .into_iter() - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.apply_property_op(i, "Fill transparency", &unlocked, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - entity.common_mut().transparency = transparency; - } - }); - iced::Task::none() - } - pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { // Default the format dropdown to the loaded file's own format — its // DWG-vs-DXF kind (from the extension) and its version (from the parsed @@ -1049,6 +65,7 @@ impl OpenCADStudio { Task::none() } + pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task { self.aec_drop_acknowledged = false; if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { @@ -1058,6 +75,7 @@ impl OpenCADStudio { Task::none() } + pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task { self.active_modal = Some(crate::app::ModalKind::Unsaved); // The unsaved-changes prompt renders inside the main window, so bring @@ -1078,6 +96,7 @@ impl OpenCADStudio { } } + pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task { if self.active_modal == Some(crate::app::ModalKind::Unsaved) { self.active_modal = None; @@ -1086,11 +105,8 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_ribbon_tool_click( - &mut self, - tool_id: String, - event: ModuleEvent, - ) -> Task { + +pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task { // Commands use `start_allowed`; other events need a drawing // and stay blocked on the Start page (#299, #388, #389). if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) { @@ -1132,8 +148,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.clear(); self.tabs[i].properties = PropertiesPanel::empty(); - self.command_line - .push_output(crate::t!("Scene cleared.").as_ref()); + self.command_line.push_output(crate::t!("Scene cleared.").as_ref()); } ModuleEvent::SetVisualStyle(name) => { use crate::modules::view::visual_style; @@ -1142,8 +157,9 @@ impl OpenCADStudio { None => { // Name the styles that do exist, from the same // list every other caller reads. - self.command_line - .push_error(crate::tf!("Unknown visual style \"{name}\".").as_ref()); + self.command_line.push_error( + crate::tf!("Unknown visual style \"{name}\".").as_ref(), + ); self.command_line .push_info(crate::t!(visual_style::keyword_prompt()).as_ref()); } @@ -1160,7 +176,8 @@ impl OpenCADStudio { } => { return Task::perform( async move { - let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + let exts: Vec<&str> = + extensions.iter().map(|s| s.as_str()).collect(); let path = crate::sys::file_dialog() .set_title(title) .add_filter(filter_name, &exts) @@ -1190,7 +207,8 @@ impl OpenCADStudio { let _ = std::fs::remove_file(self.autosave_target(idx)); if self.tabs.len() == 1 { self.tab_counter += 1; - self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter); + self.tabs[0] = + crate::app::document::DocumentTab::new_drawing(self.tab_counter); self.active_tab = 0; self.apply_display_defaults(0); } else { @@ -1231,11 +249,16 @@ impl OpenCADStudio { return Task::none(); }; let (idx, continuation) = match pending { - crate::app::PendingClose::Tab(idx) => (idx, crate::app::SaveContinuation::CloseTab), + crate::app::PendingClose::Tab(idx) => { + (idx, crate::app::SaveContinuation::CloseTab) + } crate::app::PendingClose::Quit => { let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else { self.pending_close = None; - return Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]); + return Task::batch([ + self.close_unsaved_dialog_window(), + self.exit_app(), + ]); }; (idx, crate::app::SaveContinuation::Quit) } @@ -1291,7 +314,10 @@ impl OpenCADStudio { let save = self.save_with_default_format(idx); Task::batch([close, save]) } else { - Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]) + Task::batch([ + self.close_unsaved_dialog_window(), + self.exit_app(), + ]) } } None => Task::none(), @@ -1366,10 +392,7 @@ impl OpenCADStudio { .iter() .filter_map(|h| doc.get_entity(*h)) .filter(|e| { - !matches!( - e, - acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) - ) + !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) }) .cloned() .collect() @@ -1382,10 +405,7 @@ impl OpenCADStudio { o == model_br || o.is_null() }) .filter(|e| { - !matches!( - e, - acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) - ) + !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) }) .cloned() .collect() @@ -1479,10 +499,7 @@ impl OpenCADStudio { Ok(name) } - pub(super) fn on_block_palette( - &mut self, - m: crate::ui::window::block_palette::BlockPaletteMsg, - ) -> iced::Task { + pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task { use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg}; match m { BlockPaletteMsg::Search(s) => { @@ -1504,10 +521,7 @@ impl OpenCADStudio { async { let handle = rfd::AsyncFileDialog::new() .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) - .add_filter( - crate::t!("DWG/DXF Files").as_ref(), - &["dwg", "dxf", "DWG", "DXF"], - ) + .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) .pick_file() .await; match handle { @@ -1526,8 +540,7 @@ impl OpenCADStudio { self.start_block_placement(&name); } Err(e) if e != "Cancelled" => { - self.command_line - .push_error(&crate::tf!("INSERT FILE: {e}")); + self.command_line.push_error(&crate::tf!("INSERT FILE: {e}")); } Err(_) => {} } @@ -1599,10 +612,7 @@ impl OpenCADStudio { PanelId::GraphicAttributes => { self.show_graphic_attributes = false; self.ribbon.set_graphic_attributes(false); - self.graphic_attributes_header_menu_open = false; - self.graphic_attribute_menu_open = false; - self.gradient_editor = None; - self.hatch_editor_handles = None; + self.graphic_attributes.close_popups(); self.save_config(); } } @@ -1643,12 +653,7 @@ impl OpenCADStudio { // produced a one-time jump plus a stuck drag. Column moves // arrive via Message::XrefColMove only. if self.dock_dragging.is_some() { - let avail = self.tabs[self.active_tab] - .scene - .selection - .borrow() - .vp_size - .1; + let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1; let side = if point.x < self.win_size.0 * 0.5 { DockSide::Left } else { @@ -1860,19 +865,13 @@ impl OpenCADStudio { .entities() .filter_map(|entity| match entity { acadrust::EntityType::RasterImage(image) - if image - .definition_handle - .is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => + if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => { Some(entity.common().handle) } acadrust::EntityType::Underlay(underlay) - if matches!( - underlay.underlay_type, - acadrust::entities::UnderlayType::Pdf - ) && self.tabs[i] - .xref_unloaded - .is_unloaded(underlay.definition_handle.value()) => + if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf) + && self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => { Some(entity.common().handle) } @@ -1891,14 +890,12 @@ impl OpenCADStudio { /// selection. Same engine fns as the CLI arms, batched with per-item /// report lines in the CLI wording; finishes with [`post_ref_op`] plus /// a palette rescan (mirror of the CLI post-op sequence). - pub(crate) fn xref_manager_op(&mut self, op: crate::ui::window::xref_manager::XrefPaletteOp) { + pub(crate) fn xref_manager_op( + &mut self, + op: crate::ui::window::xref_manager::XrefPaletteOp, + ) { if cfg!(target_arch = "wasm32") { - self.command_line.push_error( - crate::t!( - "Reference changes are not available on web — the reference list is read-only." - ) - .as_ref(), - ); + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); return; } use crate::ui::window::xref_manager::XrefPaletteOp; @@ -1911,10 +908,14 @@ impl OpenCADStudio { idx.sort_unstable(); idx.iter() .filter_map(|idx| { - self.xref_manager - .entries - .get(*idx) - .map(|e| (e.key, e.name.clone(), e.kind, e.parent_key.is_some())) + self.xref_manager.entries.get(*idx).map(|e| { + ( + e.key, + e.name.clone(), + e.kind, + e.parent_key.is_some(), + ) + }) }) .collect() }; @@ -1929,9 +930,8 @@ impl OpenCADStudio { ); let host: Option = self.tabs[i].current_path.clone(); if needs_host && host.is_none() { - self.command_line.push_error( - crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), - ); + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); return; } let label = match op { @@ -1956,11 +956,15 @@ impl OpenCADStudio { ).as_ref()); continue; } - match crate::io::xref::detach_reference(&mut self.tabs[i].scene.document, *key) - { + match crate::io::xref::detach_reference( + &mut self.tabs[i].scene.document, + *key, + ) { Ok(name) => { - self.command_line - .push_output(crate::tf!("XREF: detached \"{}\".", name).as_ref()); + self.command_line.push_output(crate::tf!( + "XREF: detached \"{}\".", + name + ).as_ref()); self.tabs[i].xref_unloaded.remove(key); self.tabs[i].xref_stat_cache.remove(key); done += 1; @@ -1978,11 +982,15 @@ impl OpenCADStudio { ).as_ref()); continue; } - match crate::io::xref::unload_reference(&mut self.tabs[i].scene.document, *key) - { + match crate::io::xref::unload_reference( + &mut self.tabs[i].scene.document, + *key, + ) { Ok(name) => { - self.command_line - .push_output(crate::tf!("XREF: unloaded \"{}\".", name).as_ref()); + self.command_line.push_output(crate::tf!( + "XREF: unloaded \"{}\".", + name + ).as_ref()); self.tabs[i].xref_unloaded.add(*key); done += 1; } @@ -2016,10 +1024,10 @@ impl OpenCADStudio { name ).as_ref()); } else if *kind != crate::io::xref_model::RefKind::DwgXref { - self.command_line.push_error( - crate::tf!("{}: reload applies to drawing references only.", name) - .as_ref(), - ); + self.command_line.push_error(crate::tf!( + "{}: reload applies to drawing references only.", + name + ).as_ref()); } } for (key, _) in &dwg_picked { @@ -2059,14 +1067,18 @@ impl OpenCADStudio { } } for (_, name) in &dwg_picked { - match infos.iter().find(|n| n.name.eq_ignore_ascii_case(name)) { + match infos + .iter() + .find(|n| n.name.eq_ignore_ascii_case(name)) + { Some(info) => { self.report_xref_status(info); done += 1; } - None => self.command_line.push_error( - crate::tf!("XREF: no references match '{}'.", name).as_ref(), - ), + None => self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + name + ).as_ref()), } } } @@ -2085,9 +1097,10 @@ impl OpenCADStudio { crate::io::xref_model::RefType::Overlay, ) { Ok(name) => { - self.command_line.push_output( - crate::tf!("XREF: \"{}\" set to Overlay.", name).as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Overlay.", + name + ).as_ref()); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2109,9 +1122,10 @@ impl OpenCADStudio { crate::io::xref_model::RefType::Attach, ) { Ok(name) => { - self.command_line.push_output( - crate::tf!("XREF: \"{}\" set to Attach.", name).as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Attach.", + name + ).as_ref()); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2129,13 +1143,10 @@ impl OpenCADStudio { .unwrap_or_else(|| std::path::PathBuf::from(".")); for (key, name, _, is_nested) in &picked { if *is_nested { - self.command_line.push_error( - crate::tf!( + self.command_line.push_error(crate::tf!( "XREF: cannot bind nested reference '{}'. Bind it in its host drawing.", name - ) - .as_ref(), - ); + ).as_ref()); continue; } match crate::io::xref::bind_reference( @@ -2146,9 +1157,10 @@ impl OpenCADStudio { ) { Ok(outcome) => { if outcome.unremapped == 0 { - self.command_line.push_output( - crate::tf!("XREF: bound \"{}\".", outcome.name).as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\".", + outcome.name + ).as_ref()); } else { self.command_line.push_output(crate::tf!( "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", @@ -2180,10 +1192,10 @@ impl OpenCADStudio { &host, ) { Ok(_) => { - self.command_line.push_output( - crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name) - .as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2205,25 +1217,18 @@ impl OpenCADStudio { /// full resolve, per-ref report, stat baselines refreshed. pub(crate) fn xref_manager_reload_all(&mut self) { if cfg!(target_arch = "wasm32") { - self.command_line.push_error( - crate::t!( - "Reference changes are not available on web — the reference list is read-only." - ) - .as_ref(), - ); + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); return; } let i = self.active_tab; let Some(path) = self.tabs[i].current_path.clone() else { - self.command_line.push_error( - crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), - ); + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); return; }; let Some(base_dir) = path.parent().map(|p| p.to_path_buf()) else { - self.command_line.push_error( - crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref(), - ); + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); return; }; let reload_keys: Vec = crate::io::xref::collect_entries_with_prev( @@ -2278,594 +1283,6 @@ mod tests { app } - #[test] - fn graphic_attributes_creates_one_associative_fill_per_object() { - use crate::ui::window::graphic_attributes::GraphicAttribute; - use acadrust::entities::LwPolyline; - use acadrust::types::Vector2; - - let mut app = fresh(); - let i = app.active_tab; - let mut boundaries = Vec::new(); - // Coincident boundaries must still own independent associations. - for offset in [0.0, 0.0] { - let mut polyline = LwPolyline::new(); - for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { - polyline.add_point(Vector2::new(x + offset, y)); - } - polyline.close(); - let handle = app.tabs[i] - .scene - .add_entity(EntityType::LwPolyline(polyline)); - app.tabs[i].scene.select_entity(handle, false); - boundaries.push(handle); - } - - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert!(hatches - .iter() - .all(|hatch| hatch.is_solid && hatch.is_associative)); - assert!(hatches.iter().all(|hatch| hatch - .paths - .iter() - .flat_map(|path| &path.boundary_handles) - .count() - == 1)); - - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(boundaries[0], false); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert_eq!( - hatches - .iter() - .filter(|hatch| hatch.gradient_color.enabled) - .count(), - 1 - ); - assert_eq!( - hatches - .iter() - .filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled) - .count(), - 1 - ); - - // An invalid boundary is skipped while valid selected boundaries are - // still updated. - let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); - app.tabs[i].scene.select_entity(line, false); - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - let hatches: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - Some(hatch) - }) - .collect(); - assert_eq!(hatches.len(), 2); - assert!(hatches - .iter() - .all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)); - assert!(hatches.iter().all(|hatch| hatch - .paths - .iter() - .flat_map(|path| &path.boundary_handles) - .all(|handle| *handle != line))); - - // Editing a common gradient updates each object's independent hatch - // and records the whole operation as one undo step. - app.tabs[i].scene.deselect_all(); - for handle in &boundaries { - app.tabs[i].scene.select_entity(*handle, false); - } - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - app.open_gradient_editor(); - let editor = app.gradient_editor.as_mut().expect("gradient editor"); - assert_eq!(editor.handles.len(), 2); - editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; - editor.inverted = true; - editor.angle = "405°".into(); - editor.centered = false; - let undo_before = app.tabs[i].history.undo_stack.len(); - let _ = app.apply_gradient_editor(); - assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); - - let gradients: Vec<_> = app.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - hatch.gradient_color.enabled.then_some(hatch) - }) - .collect(); - assert_eq!(gradients.len(), 2); - assert!(gradients.iter().all(|hatch| { - hatch.gradient_color.name == "INVCURVED" - && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 - && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 - })); - - // A selection change closes the flyout and it remains closed until - // the user explicitly opens it again. - app.open_gradient_editor(); - assert!(app.gradient_editor.is_some()); - app.tabs[i].scene.deselect_all(); - let _ = app.update(Message::CloseGraphicAttributeDropdown); - assert!(app.gradient_editor.is_none()); - - let selected_hatch = app.tabs[i] - .scene - .document - .entities() - .find_map(|entity| { - matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) - }) - .expect("gradient hatch"); - let associated_lines = crate::ui::window::graphic_attributes::line_handles( - &app.tabs[i].scene.document, - &[selected_hatch], - ); - assert_eq!(associated_lines.len(), 1); - assert!(boundaries.contains(&associated_lines[0])); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Gradient, - ); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(selected_hatch, false); - - // A directly selected hatch is edited in place instead of being - // rejected as an unsupported boundary object. - let _ = app.on_graphic_attribute(GraphicAttribute::Solid); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Solid, - ); - let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(3), - ); - let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("transparent solid hatch") - .common() - .transparency - .as_percent() - - 0.35) - .abs() - < 0.01 - ); - let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("ByLayer solid hatch") - .common() - .transparency - .is_by_layer()); - - // Line controls ignore HATCH entities, while fill controls ignore - // ordinary geometry in the same mixed selection. - app.tabs[i].scene.select_entity(line, false); - let _ = app.on_line_color(acadrust::types::Color::Index(4)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .color, - acadrust::types::Color::Index(4), - ); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(3), - ); - let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .color, - acadrust::types::Color::Index(5), - ); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .color, - acadrust::types::Color::Index(4), - ); - let _ = app.on_line_linetype("ByBlock".to_string()); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype, - "ByBlock", - ); - let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .line_weight, - acadrust::types::LineWeight::Value(50), - ); - assert_ne!( - app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .line_weight, - acadrust::types::LineWeight::Value(50), - ); - let _ = app.update(Message::LineLinetypeScaleInput("2.5".to_string())); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale - - 2.5) - .abs() - < f64::EPSILON - ); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .linetype_scale - - 1.0) - .abs() - < f64::EPSILON - ); - let _ = app.update(Message::LineLinetypeScaleChanged(f64::NAN)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale, - 2.5 - ); - let _ = app.update(Message::LineLinetypeScaleChanged(1.2000000000000002)); - assert_eq!( - app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .linetype_scale, - 1.2 - ); - let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .transparency - .as_percent() - - 0.2) - .abs() - < 0.01 - ); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("solid hatch") - .common() - .transparency - .is_by_layer()); - let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4)); - assert!( - (app.tabs[i] - .scene - .document - .get_entity(line) - .expect("line") - .common() - .transparency - .as_percent() - - 0.2) - .abs() - < 0.01 - ); - let pattern_name = crate::scene::model::hatch_patterns::catalog() - .iter() - .find(|entry| { - matches!( - entry.gpu, - crate::scene::model::hatch_model::HatchPattern::Pattern(_) - ) - }) - .expect("pattern hatch") - .name - .clone(); - let _ = app.update(Message::GraphicHatchPatternChanged(pattern_name.clone())); - let selected_pattern = match app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .expect("pattern hatch") - { - EntityType::Hatch(hatch) => hatch.pattern.name.as_str(), - _ => panic!("fill target is not a hatch"), - }; - assert_eq!(selected_pattern, pattern_name); - app.tabs[i].scene.deselect_all(); - app.tabs[i].scene.select_entity(selected_hatch, false); - - let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Hatch, - ); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - assert_eq!( - crate::ui::window::graphic_attributes::current( - &app.tabs[i].scene.document, - &[selected_hatch], - ), - GraphicAttribute::Gradient, - ); - app.open_gradient_editor(); - assert!(app.gradient_editor.is_some()); - let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); - assert!(app.gradient_editor.is_none()); - let _ = app.update(Message::HatchEditorOpen); - assert!(app.hatch_editor_handles.is_some()); - let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); - assert!(app.hatch_editor_handles.is_none()); - assert!(app.gradient_editor.is_none()); - - let undo_before = app.tabs[i].history.undo_stack.len(); - let _ = app.on_graphic_attribute(GraphicAttribute::None); - assert!(app.tabs[i] - .scene - .document - .get_entity(selected_hatch) - .is_none()); - assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); - } - - #[test] - fn graphic_attributes_can_close_an_open_boundary_from_the_command_prompt() { - use crate::ui::window::graphic_attributes::GraphicAttribute; - use acadrust::entities::LwPolyline; - use acadrust::types::Vector2; - - let mut app = fresh(); - let i = app.active_tab; - let mut polyline = LwPolyline::new(); - for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { - polyline.add_point(Vector2::new(x, y)); - } - let boundary = app.tabs[i] - .scene - .add_entity(EntityType::LwPolyline(polyline)); - app.tabs[i].scene.select_entity(boundary, false); - - let _ = app.update(Message::GraphicAttributeChanged(GraphicAttribute::Solid)); - assert!(app.pending_graphic_fill_close.is_some()); - assert!(!app.tabs[i].scene.document.entities().any(|entity| { - matches!(entity, EntityType::Hatch(_)) - })); - - let _ = app.update(Message::CommandOptionPick("Y".to_string())); - assert!(app.pending_graphic_fill_close.is_none()); - assert!(matches!( - app.tabs[i].scene.document.get_entity(boundary), - Some(EntityType::LwPolyline(polyline)) if polyline.is_closed - )); - assert!(app.tabs[i].scene.document.entities().any(|entity| { - matches!(entity, EntityType::Hatch(hatch) if hatch.is_solid && hatch.is_associative) - })); - } - - #[test] - fn graphic_line_controls_update_creation_defaults_without_a_selection() { - let mut app = fresh(); - let i = app.active_tab; - app.tabs[i].scene.deselect_all(); - - let _ = app.on_line_color(acadrust::types::Color::Index(2)); - let _ = app.on_line_linetype("Continuous".to_string()); - let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); - let _ = app.on_line_linetype_scale(2.5); - let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); - - let header = &app.tabs[i].scene.document.header; - assert_eq!( - header.current_entity_color, - acadrust::types::Color::Index(2) - ); - assert_eq!(header.current_linetype_name, "Continuous"); - assert_eq!(header.current_line_weight, 35); - assert!((header.current_entity_linetype_scale - 2.5).abs() < f64::EPSILON); - assert!( - (app.tabs[i] - .scene - .document - .current_entity_transparency() - .as_percent() - - 0.3) - .abs() - < 0.01 - ); - } - - #[test] - fn graphic_attributes_bulk_modes_and_remove_references_preserve_visible_values() { - let mut app = fresh(); - let i = app.active_tab; - { - let layer = app.tabs[i] - .scene - .document - .layers - .get_mut("0") - .expect("default layer"); - layer.color = acadrust::types::Color::Index(3); - layer.line_type = "Continuous".to_string(); - layer.line_weight = acadrust::types::LineWeight::Value(50); - layer.transparency = acadrust::types::Transparency::from_percent(0.25); - } - let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); - app.tabs[i].scene.select_entity(handle, false); - - let _ = app.on_graphic_attributes_set_all(false); - let common = app.tabs[i] - .scene - .document - .get_entity(handle) - .expect("line") - .common(); - assert_eq!(common.color, acadrust::types::Color::ByBlock); - assert_eq!(common.linetype, "ByBlock"); - assert_eq!(common.line_weight, acadrust::types::LineWeight::ByBlock); - assert!(common.transparency.is_by_block()); - - let _ = app.on_graphic_attributes_set_all(true); - let _ = app.on_graphic_attributes_remove_references(); - let common = app.tabs[i] - .scene - .document - .get_entity(handle) - .expect("line") - .common(); - assert_eq!(common.color, acadrust::types::Color::Index(3)); - assert_eq!(common.linetype, "Continuous"); - assert_eq!(common.line_weight, acadrust::types::LineWeight::Value(50)); - assert!((common.transparency.as_percent() - 0.25).abs() < 0.01); - } - - #[test] - fn graphic_attributes_create_layer_prompt_keeps_spaces_and_active_settings() { - let mut app = fresh(); - let i = app.active_tab; - app.tabs[i].scene.deselect_all(); - let _ = app.on_line_color(acadrust::types::Color::Index(2)); - let _ = app.on_line_linetype("Continuous".to_string()); - let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); - let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); - - let _ = app.create_graphic_attributes_layer("Invalid\nLayer"); - assert!(app.tabs[i] - .scene - .document - .layers - .get("Invalid\nLayer") - .is_none()); - - let _ = app.update(Message::GraphicAttributesCreateLayer); - assert_eq!( - app.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.name()), - Some("GRAPHICATTRIBUTELAYER") - ); - let _ = app.update(Message::CommandInput("Graphic Settings".to_string())); - let _ = app.update(Message::CommandFinalize); - - let layer = app.tabs[i] - .scene - .document - .layers - .get("Graphic Settings") - .expect("created layer"); - assert_eq!(layer.color, acadrust::types::Color::Index(2)); - assert_eq!(layer.line_type, "Continuous"); - assert_eq!(layer.line_weight, acadrust::types::LineWeight::Value(35)); - assert!((layer.transparency.as_percent() - 0.3).abs() < 0.01); - } - /// A foreign document: the fresh scene's document plus one model-space LINE. fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { let mut doc = app.tabs[app.active_tab].scene.document.clone(); @@ -2893,9 +1310,7 @@ mod tests { fn import_document_as_block_defines_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app - .import_document_as_block(doc, "Fixture".to_string()) - .unwrap(); + let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); assert_eq!(name, "Fixture"); assert!(app.tabs[app.active_tab] .scene @@ -2990,11 +1405,12 @@ mod tests { } /// The nested-INSERT's block name inside a defined block record. - fn nested_insert_target(doc: &acadrust::CadDocument, block: &str) -> Option { + fn nested_insert_target( + doc: &acadrust::CadDocument, + block: &str, + ) -> Option { let br = doc.block_records.get(block)?; - br.entity_handles - .iter() - .find_map(|h| match doc.get_entity(*h)? { + br.entity_handles.iter().find_map(|h| match doc.get_entity(*h)? { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }) @@ -3074,9 +1490,7 @@ mod tests { ) .unwrap(); - let _ = app - .import_document_as_block(doc, "Imported".to_string()) - .unwrap(); + let _ = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); let doc = &app.tabs[i].scene.document; // The file's own "Door (2)" is kept intact and targets the file's // renamed "Door" (now "Door (3)" — "Door (2)" was taken by the source). @@ -3123,9 +1537,7 @@ mod tests { ) .unwrap(); - let name = app - .import_document_as_block(doc, "Imported".to_string()) - .unwrap(); + let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); assert_eq!(name, "Imported"); let doc = &app.tabs[i].scene.document; @@ -3156,16 +1568,11 @@ mod tests { fn blockpalette_refresh_lists_and_places_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app - .import_document_as_block(doc, "Fixture".to_string()) - .unwrap(); + let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); app.refresh_block_palette(); assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture")); app.start_block_placement(&name); - let cmd = app.tabs[app.active_tab] - .active_cmd - .as_ref() - .expect("INSERT running"); + let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running"); assert_eq!(cmd.name(), "INSERT"); assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); } @@ -3221,10 +1628,7 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); assert!(app.dock.auto_collapse(id), "pin enables auto-collapse"); let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!( - !app.dock.auto_collapse(id), - "second pin disables auto-collapse" - ); + assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse"); let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); assert!(!app.show_block_palette, "close dismisses the sidebar"); } @@ -3242,9 +1646,7 @@ mod tests { ); let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); app.win_size = (1600.0, 900.0).into(); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 100.0, - ))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -3267,13 +1669,22 @@ mod tests { // Left: block palette hidden, properties shown -> 1 visible panel. app.show_block_palette = false; app.show_properties = true; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); + assert_eq!( + app.dock_visible_len(crate::app::config::DockSide::Left), + 1 + ); // Reveal the block palette -> both count. app.show_block_palette = true; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2); + assert_eq!( + app.dock_visible_len(crate::app::config::DockSide::Left), + 2 + ); // A hidden (closed) panel counts for nothing even when stacked. app.show_block_palette = false; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); + assert_eq!( + app.dock_visible_len(crate::app::config::DockSide::Left), + 1 + ); } #[test] @@ -3297,9 +1708,8 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); // Pointer near the bottom of the left edge: one visible panel means a // single slot, so every y maps to index 0 (no top/bottom split). - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 850.0, - ))); + let _ = + app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -3323,9 +1733,8 @@ mod tests { app.win_size = (1600.0, 900.0).into(); let id = crate::ui::dock::PanelId::Properties; let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 850.0, - ))); + let _ = + app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 2)) @@ -3350,11 +1759,7 @@ mod tests { line.end = Vector3::new(1.0, 0.0, 0.0); app.tabs[i] .scene - .define_block_from_owned_entities( - vec![EntityType::Line(line)], - "Chair", - glam::DVec3::ZERO, - ) + .define_block_from_owned_entities(vec![EntityType::Line(line)], "Chair", glam::DVec3::ZERO) .unwrap(); assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); assert_eq!(app.block_name_from_file("Table"), "Table"); @@ -3383,7 +1788,12 @@ mod tests { br.flags.is_xref = true; br.xref_path = "refs/plan.dwg".to_string(); br.handle = app.tabs[i].scene.document.allocate_handle(); - app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i] + .scene + .document + .block_records + .add(br) + .unwrap(); app.tabs[i].current_path = Some(old_dir.join("host.dwg")); let tab_id = app.tabs[i].id; let job_id = 4242u64; @@ -3444,9 +1854,9 @@ mod tests { // Behavioral matrix: every row op on a NESTED row via the GUI // message path. Nested rows must report per-entry errors (CLI // wording), never silently skip. + use acadrust::tables::BlockRecord; use crate::app::Message; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::tables::BlockRecord; let dir = palette_tmpdir("nestedmatrix"); let mut host_doc = acadrust::CadDocument::new(); let mut inner = BlockRecord::new("INNER"); @@ -3464,12 +1874,7 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(dir.join("app.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "INNER") - .expect("nested INNER listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); assert!(app.xref_manager.entries[idx].parent_key.is_some()); for op in [ XrefPaletteOp::Detach, @@ -3481,17 +1886,9 @@ mod tests { let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, op)); let out = palette_output(&app, start); + assert!(out.contains("INNER"), "op {op:?} on nested row gave: {out:?}"); assert!( - out.contains("INNER"), - "op {op:?} on nested row gave: {out:?}" - ); - assert!( - app.tabs[i] - .scene - .document - .block_records - .get("HOST") - .is_some(), + app.tabs[i].scene.document.block_records.get("HOST").is_some(), "op {op:?} must not mutate the direct reference" ); } @@ -3502,9 +1899,9 @@ mod tests { fn palette_detach_unload_rowop_gui_path() { // Reproduction for "Detach/Unload from the palette do nothing": // drive the exact GUI message path (row right-click menu item). + use acadrust::tables::BlockRecord; use crate::app::Message; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::tables::BlockRecord; let mut app = fresh(); let i = app.active_tab; for name in ["PLAN", "SITE"] { @@ -3516,42 +1913,19 @@ mod tests { } app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "PLAN") - .expect("PLAN listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach)); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "detach output missing, got: {out:?}"); - assert!( - app.tabs[i] - .scene - .document - .block_records - .get("PLAN") - .is_none(), - "PLAN definition must be gone" - ); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "SITE") - .expect("SITE listed"); + assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload)); let out = palette_output(&app, start); assert!(out.contains("SITE"), "unload output missing, got: {out:?}"); assert_eq!( - app.xref_manager - .entries - .iter() - .find(|e| e.name == "SITE") - .unwrap() - .status, + app.xref_manager.entries.iter().find(|e| e.name == "SITE").unwrap().status, crate::io::xref_model::RefStatus::Unloaded ); } @@ -3560,10 +1934,10 @@ mod tests { fn palette_overlay_and_pathtype_rowop_gui_path() { // Row-menu Overlay + Change-Path row ops on a direct row via the // GUI message path: type flag flips, saved path clears. + use acadrust::tables::BlockRecord; use crate::app::Message; use crate::io::xref_model::{Pathtype, RefType}; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::tables::BlockRecord; let mut app = fresh(); let i = app.active_tab; let mut br = BlockRecord::new("PLAN"); @@ -3573,49 +1947,20 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "PLAN") - .expect("PLAN listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); let start = app.command_line.history.len(); let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay)); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "got: {out:?}"); - let br = app.tabs[i] - .scene - .document - .block_records - .get("PLAN") - .unwrap(); + let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); assert!(br.flags.is_xref_overlay && !br.flags.is_xref); - assert_eq!( - app.xref_manager - .entries - .iter() - .find(|e| e.name == "PLAN") - .unwrap() - .ref_type, - RefType::Overlay - ); + assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay); let start = app.command_line.history.len(); - let _ = app.update(Message::XrefRowOp( - idx, - XrefPaletteOp::Pathtype(Pathtype::None), - )); + let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None))); let out = palette_output(&app, start); assert!(out.contains("PLAN"), "got: {out:?}"); - let br = app.tabs[i] - .scene - .document - .block_records - .get("PLAN") - .unwrap(); - assert_eq!( - br.xref_path, "plan.dwg", - "Remove Path strips to the bare filename" - ); + let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); + assert_eq!(br.xref_path, "plan.dwg", "Remove Path strips to the bare filename"); } #[test] @@ -3638,21 +1983,9 @@ mod tests { let start = app.command_line.history.len(); app.xref_manager_reload_all(); let out = palette_output(&app, start); - assert!( - out.contains("PLAN"), - "reload-all must report the reference, got: {out:?}" - ); - let entry = app - .xref_manager - .entries - .iter() - .find(|e| e.name == "PLAN") - .expect("PLAN listed"); - assert_eq!( - entry.status, - crate::io::xref_model::RefStatus::Loaded, - "PLAN must resolve Loaded" - ); + assert!(out.contains("PLAN"), "reload-all must report the reference, got: {out:?}"); + let entry = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed"); + assert_eq!(entry.status, crate::io::xref_model::RefStatus::Loaded, "PLAN must resolve Loaded"); std::fs::remove_dir_all(&dir).ok(); } @@ -3670,8 +2003,8 @@ mod tests { fn palette_unload_nested_reports_per_entry() { // F6: palette Unload on a nested row reports the per-entry nested // error (CLI wording) instead of the confusing 'no loaded reference'. - use crate::ui::window::xref_manager::XrefPaletteOp; use acadrust::tables::BlockRecord; + use crate::ui::window::xref_manager::XrefPaletteOp; let dir = palette_tmpdir("nested"); let mut host_doc = acadrust::CadDocument::new(); let mut inner = BlockRecord::new("INNER"); @@ -3689,12 +2022,7 @@ mod tests { app.tabs[i].scene.document.block_records.add(br).unwrap(); app.tabs[i].current_path = Some(dir.join("app.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.name == "INNER") - .expect("nested INNER listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); assert!(app.xref_manager.entries[idx].parent_key.is_some()); app.xref_manager.selected.insert(idx); let start = app.command_line.history.len(); @@ -3710,20 +2038,16 @@ mod tests { // F7: palette Reload on an image/PDF row reports the drawing-only // error and leaves its unloaded flag untouched (no spurious // no-match after a flag clear). + use acadrust::objects::{ImageDefinition, ObjectType}; use crate::io::xref_model::RefKind; use crate::ui::window::xref_manager::XrefPaletteOp; - use acadrust::objects::{ImageDefinition, ObjectType}; let dir = palette_tmpdir("imgreload"); let mut app = fresh(); let i = app.active_tab; let h = app.tabs[i].scene.document.allocate_handle(); let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); def.handle = h; - app.tabs[i] - .scene - .document - .objects - .insert(h, ObjectType::ImageDefinition(def)); + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); let mut img = acadrust::entities::RasterImage::new( "img.png", acadrust::types::Vector3::ZERO, @@ -3731,19 +2055,10 @@ mod tests { 8.0, ); img.definition_handle = Some(h); - app.tabs[i] - .scene - .document - .add_entity(acadrust::EntityType::RasterImage(img)) - .unwrap(); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); app.tabs[i].current_path = Some(dir.join("host.dwg")); app.refresh_xref_manager(); - let idx = app - .xref_manager - .entries - .iter() - .position(|e| e.kind == RefKind::Image) - .expect("image listed"); + let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed"); let key = app.xref_manager.entries[idx].key; app.xref_manager.selected.insert(idx); app.tabs[i].xref_unloaded.add(key); @@ -3752,10 +2067,7 @@ mod tests { let out = palette_output(&app, start); assert!(out.contains("img.png"), "got: {out:?}"); assert_eq!(app.command_line.history.len(), start + 1); - assert!( - app.tabs[i].xref_unloaded.is_unloaded(key), - "image flag must stay untouched" - ); + assert!(app.tabs[i].xref_unloaded.is_unloaded(key), "image flag must stay untouched"); std::fs::remove_dir_all(&dir).ok(); } } diff --git a/src/app/update/graphic_attributes.rs b/src/app/update/graphic_attributes.rs new file mode 100644 index 000000000..52fb76ace --- /dev/null +++ b/src/app/update/graphic_attributes.rs @@ -0,0 +1,1693 @@ +//! Graphic Attributes palette: applies line attributes and associative fills +//! to the selection, or to the creation defaults when nothing is selected. + +use crate::app::{Message, OpenCADStudio}; +use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; +use crate::ui::window::gradient_editor::{GradientColorMode, GradientEditorState, GradientMsg}; +use crate::ui::window::graphic_attributes::{ + self as palette, GraphicAttribute, GraphicAttributesMsg, HatchEditorState, PendingFillClose, +}; +use acadrust::types::{Color as AcadColor, LineWeight, Transparency}; +use acadrust::{EntityType, Handle}; +use iced::Task; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Which objects of the selection a palette control edits. +#[derive(Clone, Copy)] +enum Part { + /// Non-HATCH objects and the boundaries of selected associative fills. + Line, + /// Selected HATCH entities and the fills associated with selected objects. + Fill, + Both, +} + +/// The first line pattern of the catalogue, used when a fill becomes a hatch. +fn default_hatch_pattern() -> Option<&'static crate::scene::model::hatch_patterns::PatternEntry> { + crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| matches!(entry.gpu, HatchPattern::Pattern(_))) +} + +/// Pattern name and render pattern of a new fill; `None` for no fill. +fn fill_pattern(value: GraphicAttribute) -> Option<(String, HatchPattern)> { + match value { + GraphicAttribute::Solid => Some(("SOLID".into(), HatchPattern::Solid)), + GraphicAttribute::Hatch => { + default_hatch_pattern().map(|entry| (entry.name.clone(), entry.gpu.clone())) + } + GraphicAttribute::Gradient => Some(( + "LINEAR".into(), + HatchPattern::Gradient { + angle_deg: 0.0, + color2: [0.18, 0.18, 0.18, 0.0], + kind: GradientKind::Linear, + invert: false, + shift: 0.0, + }, + )), + GraphicAttribute::None | GraphicAttribute::Varies => None, + } +} + +/// Convert a selected HATCH to another fill kind in place. +fn set_hatch_graphic_attribute( + hatch: &mut acadrust::entities::Hatch, + value: GraphicAttribute, + visible_color: [f32; 4], +) { + match value { + GraphicAttribute::Solid => { + hatch.pattern = acadrust::entities::HatchPattern::new("SOLID"); + hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; + hatch.is_solid = true; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Hatch => { + let Some(entry) = default_hatch_pattern() else { + return; + }; + let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + crate::entities::hatch::scale_pattern_geometry( + &mut pattern, + hatch.pattern_scale.max(1.0e-6), + ); + crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); + let origin = hatch.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); + hatch.pattern = pattern; + hatch.pattern_type = acadrust::entities::HatchPatternType::Predefined; + hatch.is_solid = false; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Gradient => { + let channel = |value: f32| (value * 255.0).round().clamp(0.0, 255.0) as u8; + hatch.is_solid = true; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + hatch.gradient_color.angle = 0.0; + hatch.pattern_angle = 0.0; + hatch.gradient_color.shift = 0.0; + hatch.gradient_color.is_single_color = false; + hatch.gradient_color.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: AcadColor::Rgb { + r: channel(visible_color[0]), + g: channel(visible_color[1]), + b: channel(visible_color[2]), + }, + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: AcadColor::Rgb { + r: 46, + g: 46, + b: 46, + }, + }, + ]; + } + GraphicAttribute::None | GraphicAttribute::Varies => {} + } +} + +fn is_open_fill_boundary(entity: &EntityType) -> bool { + match entity { + EntityType::LwPolyline(polyline) => !polyline.is_closed && polyline.vertices.len() >= 3, + EntityType::Polyline(polyline) => { + !polyline.flags.is_closed() && polyline.vertices.len() >= 3 + } + EntityType::Polyline2D(polyline) => { + !polyline.is_closed() && polyline.vertices.len() >= 3 + } + EntityType::Polyline3D(polyline) => { + !polyline.flags.closed && polyline.vertices.len() >= 3 + } + _ => false, + } +} + +fn close_fill_boundary(entity: &mut EntityType) -> bool { + match entity { + EntityType::LwPolyline(polyline) if !polyline.is_closed => { + polyline.is_closed = true; + true + } + EntityType::Polyline(polyline) if !polyline.flags.is_closed() => { + polyline.flags.set_closed(true); + true + } + EntityType::Polyline2D(polyline) if !polyline.is_closed() => { + polyline.flags.set_closed(true); + true + } + EntityType::Polyline3D(polyline) if !polyline.flags.closed => { + polyline.flags.closed = true; + true + } + _ => false, + } +} + +/// Line attributes with ByLayer / ByBlock resolved to the values they show. +struct ConcreteGraphicAttributes { + color: AcadColor, + linetype: String, + lineweight: LineWeight, + transparency: Transparency, +} + +fn concrete_graphic_attributes( + color: AcadColor, + linetype: &str, + lineweight: LineWeight, + transparency: Transparency, + layer: Option<&acadrust::tables::Layer>, +) -> ConcreteGraphicAttributes { + let layer_color = layer.map_or(AcadColor::Index(7), |layer| match layer.color { + AcadColor::ByLayer | AcadColor::ByBlock => AcadColor::Index(7), + color => color, + }); + let layer_linetype = layer + .map(|layer| layer.line_type.as_str()) + .filter(|value| { + !value.is_empty() + && !value.eq_ignore_ascii_case("ByLayer") + && !value.eq_ignore_ascii_case("ByBlock") + }) + .unwrap_or("Continuous"); + let layer_lineweight = layer.map_or(LineWeight::Default, |layer| match layer.line_weight { + LineWeight::ByLayer | LineWeight::ByBlock => LineWeight::Default, + value => value, + }); + let layer_transparency = layer + .map(|layer| layer.transparency) + .filter(|value| !value.is_by_layer() && !value.is_by_block()) + .unwrap_or_else(|| Transparency::from_percent(0.0)); + + ConcreteGraphicAttributes { + color: match color { + AcadColor::ByLayer => layer_color, + AcadColor::ByBlock => AcadColor::Index(7), + value => value, + }, + linetype: if linetype.is_empty() || linetype.eq_ignore_ascii_case("ByLayer") { + layer_linetype.to_owned() + } else if linetype.eq_ignore_ascii_case("ByBlock") { + "Continuous".to_owned() + } else { + linetype.to_owned() + }, + lineweight: match lineweight { + LineWeight::ByLayer => layer_lineweight, + LineWeight::ByBlock => LineWeight::Default, + value => value, + }, + transparency: if transparency.is_by_layer() { + layer_transparency + } else if transparency.is_by_block() { + Transparency::from_percent(0.0) + } else { + transparency + }, + } +} + +impl OpenCADStudio { + pub(super) fn on_graphic_attributes(&mut self, message: GraphicAttributesMsg) -> Task { + use GraphicAttributesMsg as M; + if !matches!( + message, + M::ToggleMenu(_) | M::HatchPatternSearch(_) | M::HatchPatternFocus(_) | M::Gradient(_) + ) { + self.graphic_attributes.open_menu = None; + } + match message { + M::ToggleMenu(menu) => { + let state = &mut self.graphic_attributes; + state.open_menu = (state.open_menu != Some(menu)).then_some(menu); + Task::none() + } + M::CloseMenu => Task::none(), + M::SetAllByLayer => self.on_graphic_attributes_set_all(true), + M::SetAllByBlock => self.on_graphic_attributes_set_all(false), + M::RemoveReferences => self.on_graphic_attributes_remove_references(), + M::CreateLayer => { + use crate::command::CadCommand; + let command = crate::command::FreeTextValuePromptCommand::new( + "GRAPHICATTRIBUTELAYER", + "Enter a name for the new layer:", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + self.focus_cmd_input() + } + M::Fill(value) => self.on_graphic_attribute(value), + M::LineColor(color) => self.on_line_color(color), + M::LineLinetype(linetype) => self.on_line_linetype(linetype), + M::LineLineweight(lineweight) => self.on_line_lineweight(lineweight), + M::LineLinetypeScale(scale) => self.on_line_linetype_scale(scale), + M::LineTransparency(transparency) => self.on_line_transparency(transparency), + M::FillColor(color) => self.on_solid_fill_color(color), + M::FillTransparency(transparency) => self.on_fill_transparency(transparency), + M::HatchEditorOpen => self.open_hatch_editor(), + M::HatchEditorClose => { + self.graphic_attributes.hatch_editor = None; + Task::none() + } + M::HatchPatternSearch(search) => { + if let Some(editor) = &mut self.graphic_attributes.hatch_editor { + editor.search = search; + editor.focus = 0; + } + Task::none() + } + M::HatchPatternFocus(index) => { + if let Some(editor) = &mut self.graphic_attributes.hatch_editor { + if index < crate::ui::properties::filtered_hatch_patterns(&editor.search).len() + { + editor.focus = index; + } + } + Task::none() + } + M::HatchPattern(name) => self.apply_graphic_hatch_pattern(name), + M::HatchPatternConfirm => { + let name = self.graphic_attributes.hatch_editor.as_ref().and_then(|editor| { + crate::ui::properties::filtered_hatch_patterns(&editor.search) + .get(editor.focus) + .map(|entry| entry.name.clone()) + }); + match name { + Some(name) => self.apply_graphic_hatch_pattern(name), + None => Task::none(), + } + } + M::Gradient(message) => self.on_gradient_editor(message), + } + } + + /// Prompt answers belong to the fill question only while no command runs. + pub(super) fn awaiting_fill_close_answer(&self) -> bool { + self.graphic_attributes.pending_fill_close.is_some() + && self.tabs[self.active_tab].active_cmd.is_none() + } + + /// Answer the "close open boundaries first?" prompt; Enter means Yes. + pub(super) fn on_graphic_fill_close_input(&mut self, input: &str) -> Task { + match input.trim().to_ascii_uppercase().as_str() { + "" | "Y" | "YES" => self.on_graphic_fill_close_response(true), + "N" | "NO" => self.on_graphic_fill_close_response(false), + _ => { + self.command_line.push_error("Enter Yes or No."); + Task::none() + } + } + } + + fn graphic_handles(&self, i: usize, part: Part) -> Vec { + let selected = self.tabs[i].scene.selected_handles_in_order(); + let document = &self.tabs[i].scene.document; + match part { + Part::Line => palette::line_handles(document, &selected), + Part::Fill => palette::fill_handles(document, &selected), + Part::Both => { + let mut seen = FxHashSet::default(); + palette::line_handles(document, &selected) + .into_iter() + .chain(palette::fill_handles(document, &selected)) + .filter(|handle| seen.insert(*handle)) + .collect() + } + } + } + + fn unlocked_graphic_handles(&self, i: usize, part: Part) -> Vec { + let mut handles = self.graphic_handles(i, part); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + handles + } + + /// Edit the unlocked `part` of the selection as one undo step. + fn edit_graphic_part(&mut self, part: Part, label: &str, mut edit: impl FnMut(&mut EntityType)) { + let i = self.active_tab; + let handles = self.unlocked_graphic_handles(i, part); + self.apply_property_op(i, label, &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + edit(entity); + } + }); + } + + fn has_selection(&self) -> bool { + !self.tabs[self.active_tab].scene.selected_handles_in_order().is_empty() + } + + fn current_graphic_attributes(&self, i: usize) -> ConcreteGraphicAttributes { + let document = &self.tabs[i].scene.document; + let handles = self.graphic_handles(i, Part::Both); + let draw_depth = self.tabs[i].scene.draw_depth_map(); + if let Some(entity) = + palette::top_handle(&handles, &draw_depth).and_then(|handle| document.get_entity(handle)) + { + let common = entity.common(); + return concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ); + } + + let header = &document.header; + let layer_name = if header.current_layer_name.is_empty() { + self.tabs[i].active_layer.as_str() + } else { + header.current_layer_name.as_str() + }; + concrete_graphic_attributes( + header.current_entity_color, + &header.current_linetype_name, + LineWeight::from_value(header.current_line_weight), + document.current_entity_transparency(), + document.layers.get(layer_name), + ) + } + + /// Store line attributes as the creation defaults (CECOLOR, CELTYPE, + /// CELWEIGHT, CETRANSPARENCY). + fn set_current_graphic_attributes( + &mut self, + color: AcadColor, + linetype: String, + lineweight: LineWeight, + transparency: Transparency, + ) { + let i = self.active_tab; + let linetype_handle = self.tabs[i] + .scene + .document + .line_types + .iter() + .find(|line_type| line_type.name.eq_ignore_ascii_case(&linetype)) + .map_or(Handle::NULL, |line_type| line_type.handle); + let header = &mut self.tabs[i].scene.document.header; + header.current_entity_color = color; + header.current_linetype_name = linetype; + header.current_linetype_handle = linetype_handle; + header.current_line_weight = lineweight.value(); + self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency); + self.tabs[i].dirty = true; + self.sync_ribbon_from_selection(); + self.refresh_properties(); + } + + pub(super) fn on_graphic_attributes_set_all(&mut self, by_layer: bool) -> Task { + let (color, linetype, lineweight, transparency) = if by_layer { + (AcadColor::ByLayer, "ByLayer", LineWeight::ByLayer, Transparency::BY_LAYER) + } else { + (AcadColor::ByBlock, "ByBlock", LineWeight::ByBlock, Transparency::BY_BLOCK) + }; + if !self.has_selection() { + self.set_current_graphic_attributes(color, linetype.to_owned(), lineweight, transparency); + return Task::none(); + } + self.edit_graphic_part(Part::Both, "Graphic Attributes", |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + entity.common_mut().transparency = transparency; + }); + Task::none() + } + + pub(super) fn on_graphic_attributes_remove_references(&mut self) -> Task { + let i = self.active_tab; + if !self.has_selection() { + let values = self.current_graphic_attributes(i); + self.set_current_graphic_attributes( + values.color, + values.linetype, + values.lineweight, + values.transparency, + ); + return Task::none(); + } + + let document = &self.tabs[i].scene.document; + let values: FxHashMap<_, _> = self + .unlocked_graphic_handles(i, Part::Both) + .into_iter() + .filter_map(|handle| { + let common = document.get_entity(handle)?.common(); + Some(( + handle, + concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ), + )) + }) + .collect(); + let handles: Vec<_> = values.keys().copied().collect(); + self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| { + let (Some(values), Some(entity)) = ( + values.get(&handle), + app.tabs[i].scene.document.get_entity_mut(handle), + ) else { + return; + }; + crate::scene::view::dispatch::apply_color(entity, values.color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &values.linetype); + crate::scene::view::dispatch::apply_line_weight(entity, values.lineweight); + entity.common_mut().transparency = values.transparency; + }); + Task::none() + } + + pub(crate) fn create_graphic_attributes_layer(&mut self, raw_name: &str) -> Task { + let i = self.active_tab; + let name = raw_name.trim(); + if !crate::scene::valid_block_name(name) { + self.command_line + .push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`."); + return Task::none(); + } + if self.tabs[i] + .scene + .document + .layers + .iter() + .any(|layer| layer.name.eq_ignore_ascii_case(name)) + { + self.command_line.push_error( + crate::tf!("Graphic Attributes: layer \"{name}\" already exists.").as_ref(), + ); + return Task::none(); + } + + let values = self.current_graphic_attributes(i); + let layer_name = name.to_owned(); + let undo = self.begin_layer_undo( + i, + "Create layer with active settings", + std::slice::from_ref(&layer_name), + ); + let mut layer = acadrust::tables::Layer::new(name); + layer.handle = self.tabs[i].scene.document.allocate_handle(); + layer.color = values.color; + layer.line_type = values.linetype; + layer.line_weight = values.lineweight; + layer.transparency = values.transparency; + if self.tabs[i].scene.document.layers.add(layer).is_err() { + self.command_line + .push_error("Graphic Attributes: the layer could not be created."); + return Task::none(); + } + self.tabs[i].dirty = true; + self.commit_layer_undo(i, undo); + self.refresh_layer_panel(); + self.command_line.push_output( + crate::tf!("Created layer \"{name}\" with the active Graphic Attributes settings.") + .as_ref(), + ); + Task::none() + } + + fn open_hatch_editor(&mut self) -> Task { + self.graphic_attributes.gradient_editor = None; + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let current = palette::fill_handles(document, &selected) + .into_iter() + .find_map(|handle| match document.get_entity(handle)? { + EntityType::Hatch(hatch) => Some(hatch.pattern.name.as_str()), + _ => None, + }); + let focus = current + .and_then(|current| { + crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(current)) + }) + .unwrap_or(0); + self.graphic_attributes.hatch_editor = Some(HatchEditorState { + handles: selected, + search: String::new(), + focus, + }); + iced::widget::operation::focus(iced::widget::Id::new("graphic-hatch-pattern-search")) + } + + fn apply_graphic_hatch_pattern(&mut self, name: String) -> Task { + self.graphic_attributes.hatch_editor = None; + let i = self.active_tab; + let handles = self.graphic_handles(i, Part::Fill); + self.apply_hatch_pattern(i, &handles, name) + } + + fn on_gradient_editor(&mut self, message: GradientMsg) -> Task { + match message { + GradientMsg::Open => { + self.graphic_attributes.hatch_editor = None; + self.open_gradient_editor(); + } + GradientMsg::Cancel => self.graphic_attributes.gradient_editor = None, + GradientMsg::Apply => return self.apply_gradient_editor(), + message => { + if let Some(editor) = &mut self.graphic_attributes.gradient_editor { + editor.update(message); + } + } + } + Task::none() + } + + pub(super) fn open_gradient_editor(&mut self) { + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let gradient = palette::fill_handles(document, &selected) + .into_iter() + .find_map(|handle| match document.get_entity(handle)? { + EntityType::Hatch(hatch) if hatch.gradient_color.enabled => Some(hatch), + _ => None, + }); + if let Some(hatch) = gradient { + self.graphic_attributes.gradient_editor = + Some(GradientEditorState::from_hatch(selected, hatch)); + } + } + + pub(super) fn apply_gradient_editor(&mut self) -> Task { + let Some(state) = self.graphic_attributes.gradient_editor.take() else { + return Task::none(); + }; + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let targets: Vec<_> = palette::fill_handles(document, &state.handles) + .into_iter() + .filter(|handle| { + matches!(document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled) + }) + .collect(); + if targets.is_empty() { + return Task::none(); + } + if targets + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error("Gradient Fill: the fill is on a locked layer."); + self.graphic_attributes.gradient_editor = Some(state); + return Task::none(); + } + let angle = state.angle_radians(); + let pending = self.begin_undo(i, "Edit gradient", targets.len(), true); + for handle in &targets { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if let EntityType::Hatch(hatch) = &mut entity { + let gradient = &mut hatch.gradient_color; + gradient.name = state.kind.dxf_name(state.inverted).into(); + gradient.angle = angle; + gradient.shift = if state.centered { 0.0 } else { 1.0 }; + gradient.is_single_color = state.color_mode == GradientColorMode::One; + gradient.color_tint = state.shade_tint as f64; + gradient.colors = vec![ + acadrust::entities::hatch::GradientColorEntry { + value: 0.0, + color: state.color_1, + }, + acadrust::entities::hatch::GradientColorEntry { + value: 1.0, + color: state.color_2, + }, + ]; + hatch.pattern_angle = angle; + } + self.tabs[i].scene.update_entity(entity); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + Task::none() + } + + /// Give every selected object the chosen fill. Open polylines first ask + /// whether they should be closed. + pub(super) fn on_graphic_attribute(&mut self, value: GraphicAttribute) -> Task { + self.graphic_attributes.close_popups(); + self.graphic_attributes.pending_fill_close = None; + if value == GraphicAttribute::Varies { + return Task::none(); + } + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + self.command_line + .push_info(crate::t!("Select one or more closed objects first.").as_ref()); + return Task::none(); + } + if value != GraphicAttribute::None { + let open_boundaries: Vec<_> = selected + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(is_open_fill_boundary) + }) + .collect(); + if !open_boundaries.is_empty() { + self.graphic_attributes.pending_fill_close = Some(PendingFillClose { + fill: value, + open_boundaries, + selection: selected, + }); + return Task::none(); + } + } + self.apply_graphic_fill(i, value, &selected, &[]) + } + + pub(super) fn on_graphic_fill_close_response(&mut self, close: bool) -> Task { + let Some(pending) = self.graphic_attributes.pending_fill_close.take() else { + return Task::none(); + }; + let i = self.active_tab; + let to_close: Vec<_> = if close { + pending + .open_boundaries + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect() + } else { + Vec::new() + }; + self.apply_graphic_fill(i, pending.fill, &pending.selection, &to_close) + } + + /// Close `to_close` and replace the fills of `selected`, as one undo step. + fn apply_graphic_fill( + &mut self, + i: usize, + value: GraphicAttribute, + selected: &[Handle], + to_close: &[Handle], + ) -> Task { + let pending = self.begin_undo(i, "Graphic Attributes", selected.len(), true); + let mut changed = false; + for handle in to_close { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if close_fill_boundary(&mut entity) { + self.tabs[i].scene.update_entity(entity); + changed = true; + } + } + changed |= self.replace_graphic_fills(i, value, selected); + if changed { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + Task::none() + } + + /// Replace the fills of `selected` by one associative fill per object. + /// Returns whether the document changed. + fn replace_graphic_fills( + &mut self, + i: usize, + value: GraphicAttribute, + selected: &[Handle], + ) -> bool { + let document = &self.tabs[i].scene.document; + let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None + { + (palette::fill_handles(document, selected), Vec::new(), Vec::new(), 0) + } else { + let Some((name, pattern)) = fill_pattern(value) else { + self.command_line + .push_error("Graphic Attributes: no hatch pattern is available."); + return false; + }; + let working = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + let normal = working.z.normalize_or(glam::DVec3::Z); + let storage = crate::entities::curve::ocs_plane( + acadrust::types::Vector3::new(normal.x, normal.y, normal.z), + working.origin.dot(normal), + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); + let origin = document.hatch_origin(); + let mut new_hatches = Vec::new(); + let mut updated_hatches = Vec::new(); + let mut directly_updated = FxHashSet::default(); + let mut changed_boundaries = FxHashSet::default(); + let mut skipped = 0; + for handle in selected { + let Some(entity) = document.get_entity(*handle) else { + continue; + }; + if let EntityType::Hatch(hatch) = entity { + let mut hatch = hatch.clone(); + let visible_color = + crate::scene::view::render::render_style_for_common_viewport( + document, + &hatch.common, + None, + ) + .0; + set_hatch_graphic_attribute(&mut hatch, value, visible_color); + updated_hatches.push(EntityType::Hatch(hatch)); + directly_updated.insert(*handle); + continue; + } + // Object mode only needs this object's source. Including all + // scene sources can associate coincident, unselected objects. + let object_sources = sources + .get(handle) + .map(|source| (*handle, source.clone())) + .into_iter() + .collect(); + let command = crate::modules::draw::draw::hatch::HatchCommand::new( + Vec::new(), + object_sources, + vec![*handle], + None, + plane, + ) + .with_origin(origin) + .with_pattern(name.clone(), pattern.clone()); + if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { + new_hatches.push(hatch); + changed_boundaries.insert(*handle); + } else if !is_open_fill_boundary(entity) { + // Open boundaries the user chose not to close are expected + // to stay unfilled; anything else is unsupported. + skipped += 1; + } + } + // Replace only fills belonging to boundaries that successfully + // produced a new hatch. Existing fills on skipped objects remain. + let associated: Vec<_> = document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + (hatch.is_associative + && !directly_updated.contains(&entity.common().handle) + && hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .any(|handle| changed_boundaries.contains(handle))) + .then_some(entity.common().handle) + }) + .collect(); + (associated, new_hatches, updated_hatches, skipped) + }; + if associated + .iter() + .chain(updated_hatches.iter().map(|entity| &entity.common().handle)) + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error("Graphic Attributes: an associated fill is on a locked layer."); + return false; + } + if skipped == 1 { + self.command_line + .push_info("Graphic Attributes: 1 unsupported object was not changed."); + } else if skipped > 1 { + self.command_line.push_info(&format!( + "Graphic Attributes: {skipped} unsupported objects were not changed." + )); + } + if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { + return false; + } + self.tabs[i].scene.erase_entities(&associated); + for entity in updated_hatches { + self.tabs[i].scene.update_entity(entity); + } + let layer = self.tabs[i].active_layer.clone(); + for hatch in new_hatches { + self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); + } + true + } + + pub(super) fn on_solid_fill_color(&mut self, color: AcadColor) -> Task { + self.edit_graphic_part(Part::Fill, "Fill color", |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + }); + Task::none() + } + + pub(super) fn on_fill_transparency(&mut self, transparency: Transparency) -> Task { + self.edit_graphic_part(Part::Fill, "Fill transparency", |entity| { + entity.common_mut().transparency = transparency; + }); + Task::none() + } + + pub(super) fn on_line_color(&mut self, color: AcadColor) -> Task { + if !self.has_selection() { + return self.on_ribbon_color_changed(color); + } + self.edit_graphic_part(Part::Line, "Line color", |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + }); + Task::none() + } + + pub(super) fn on_line_linetype(&mut self, linetype: String) -> Task { + if !self.has_selection() { + return self.on_ribbon_linetype_changed(linetype); + } + self.edit_graphic_part(Part::Line, "Line linetype", |entity| { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); + }); + Task::none() + } + + pub(super) fn on_line_lineweight(&mut self, lineweight: LineWeight) -> Task { + if !self.has_selection() { + return self.update(Message::RibbonLineweightChanged(lineweight)); + } + self.edit_graphic_part(Part::Line, "Line weight", |entity| { + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + }); + Task::none() + } + + pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> Task { + let Some(scale) = palette::normalized_linetype_scale(scale) else { + return Task::none(); + }; + if !self.has_selection() { + let i = self.active_tab; + self.tabs[i].scene.document.header.current_entity_linetype_scale = scale; + self.tabs[i].dirty = true; + self.refresh_properties(); + return Task::none(); + } + self.edit_graphic_part(Part::Line, "Linetype scale", |entity| { + entity.common_mut().linetype_scale = scale; + }); + Task::none() + } + + pub(super) fn on_line_transparency(&mut self, transparency: Transparency) -> Task { + if !self.has_selection() { + let i = self.active_tab; + if self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency) + { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } + self.edit_graphic_part(Part::Line, "Line transparency", |entity| { + entity.common_mut().transparency = transparency; + }); + Task::none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Line, LwPolyline}; + use acadrust::types::Vector2; + + fn ga(message: GraphicAttributesMsg) -> Message { + Message::GraphicAttributes(message) + } + + fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn graphic_attributes_creates_one_associative_fill_per_object() { + let mut app = fresh(); + let i = app.active_tab; + let mut boundaries = Vec::new(); + // Coincident boundaries must still own independent associations. + for offset in [0.0, 0.0] { + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x + offset, y)); + } + polyline.close(); + let handle = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(handle, false); + boundaries.push(handle); + } + + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert!(hatches + .iter() + .all(|hatch| hatch.is_solid && hatch.is_associative)); + assert!(hatches.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .count() + == 1)); + + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(boundaries[0], false); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert_eq!( + hatches + .iter() + .filter(|hatch| hatch.gradient_color.enabled) + .count(), + 1 + ); + assert_eq!( + hatches + .iter() + .filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled) + .count(), + 1 + ); + + // An invalid boundary is skipped while valid selected boundaries are + // still updated. + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(line, false); + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let hatches: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + Some(hatch) + }) + .collect(); + assert_eq!(hatches.len(), 2); + assert!(hatches + .iter() + .all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)); + assert!(hatches.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .all(|handle| *handle != line))); + + // Editing a common gradient updates each object's independent hatch + // and records the whole operation as one undo step. + app.tabs[i].scene.deselect_all(); + for handle in &boundaries { + app.tabs[i].scene.select_entity(*handle, false); + } + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + app.open_gradient_editor(); + let editor = app.graphic_attributes.gradient_editor.as_mut().expect("gradient editor"); + assert_eq!(editor.handles.len(), 2); + editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; + editor.inverted = true; + editor.angle = "405°".into(); + editor.centered = false; + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_gradient_editor(); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let gradients: Vec<_> = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + hatch.gradient_color.enabled.then_some(hatch) + }) + .collect(); + assert_eq!(gradients.len(), 2); + assert!(gradients.iter().all(|hatch| { + hatch.gradient_color.name == "INVCURVED" + && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 + && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 + })); + + // A selection change closes the flyout and it remains closed until + // the user explicitly opens it again. + app.open_gradient_editor(); + assert!(app.graphic_attributes.gradient_editor.is_some()); + app.tabs[i].scene.deselect_all(); + let _ = app.update(ga(GraphicAttributesMsg::CloseMenu)); + assert!(app.graphic_attributes.gradient_editor.is_none()); + + let selected_hatch = app.tabs[i] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle) + }) + .expect("gradient hatch"); + let associated_lines = palette::line_handles( + &app.tabs[i].scene.document, + &[selected_hatch], + ); + assert_eq!(associated_lines.len(), 1); + assert!(boundaries.contains(&associated_lines[0])); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Gradient, + ); + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(selected_hatch, false); + + // A directly selected hatch is edited in place instead of being + // rejected as an unsupported boundary object. + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Solid, + ); + let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(3), + ); + let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("transparent solid hatch") + .common() + .transparency + .as_percent() + - 0.35) + .abs() + < 0.01 + ); + let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("ByLayer solid hatch") + .common() + .transparency + .is_by_layer()); + + // Line controls ignore HATCH entities, while fill controls ignore + // ordinary geometry in the same mixed selection. + app.tabs[i].scene.select_entity(line, false); + let _ = app.on_line_color(acadrust::types::Color::Index(4)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .color, + acadrust::types::Color::Index(4), + ); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(3), + ); + let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .color, + acadrust::types::Color::Index(5), + ); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .color, + acadrust::types::Color::Index(4), + ); + let _ = app.on_line_linetype("ByBlock".to_string()); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype, + "ByBlock", + ); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .line_weight, + acadrust::types::LineWeight::Value(50), + ); + assert_ne!( + app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .line_weight, + acadrust::types::LineWeight::Value(50), + ); + let _ = app.on_line_linetype_scale(2.5); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale + - 2.5) + .abs() + < f64::EPSILON + ); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .linetype_scale + - 1.0) + .abs() + < f64::EPSILON + ); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN))); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale, + 2.5 + ); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(1.2000000000000002))); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale, + 1.2 + ); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .transparency + .as_percent() + - 0.2) + .abs() + < 0.01 + ); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("solid hatch") + .common() + .transparency + .is_by_layer()); + let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4)); + assert!( + (app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .transparency + .as_percent() + - 0.2) + .abs() + < 0.01 + ); + let pattern_name = crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| { + matches!( + entry.gpu, + crate::scene::model::hatch_model::HatchPattern::Pattern(_) + ) + }) + .expect("pattern hatch") + .name + .clone(); + let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern_name.clone()))); + let selected_pattern = match app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .expect("pattern hatch") + { + EntityType::Hatch(hatch) => hatch.pattern.name.as_str(), + _ => panic!("fill target is not a hatch"), + }; + assert_eq!(selected_pattern, pattern_name); + app.tabs[i].scene.deselect_all(); + app.tabs[i].scene.select_entity(selected_hatch, false); + + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Hatch, + ); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert_eq!( + palette::current( + &app.tabs[i].scene.document, + &[selected_hatch], + ), + GraphicAttribute::Gradient, + ); + app.open_gradient_editor(); + assert!(app.graphic_attributes.gradient_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert!(app.graphic_attributes.gradient_editor.is_none()); + let _ = app.update(ga(GraphicAttributesMsg::HatchEditorOpen)); + assert!(app.graphic_attributes.hatch_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert!(app.graphic_attributes.hatch_editor.is_none()); + assert!(app.graphic_attributes.gradient_editor.is_none()); + + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.on_graphic_attribute(GraphicAttribute::None); + assert!(app.tabs[i] + .scene + .document + .get_entity(selected_hatch) + .is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + } + + #[test] + fn graphic_attributes_can_close_an_open_boundary_from_the_command_prompt() { + let mut app = fresh(); + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let boundary = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(boundary, false); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert!(app.graphic_attributes.pending_fill_close.is_some()); + assert!(!app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(_)) + })); + + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(matches!( + app.tabs[i].scene.document.get_entity(boundary), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + )); + assert!(app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(hatch) if hatch.is_solid && hatch.is_associative) + })); + } + + #[test] + fn graphic_line_controls_update_creation_defaults_without_a_selection() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + + let _ = app.on_line_color(acadrust::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); + let _ = app.on_line_linetype_scale(2.5); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); + + let header = &app.tabs[i].scene.document.header; + assert_eq!( + header.current_entity_color, + acadrust::types::Color::Index(2) + ); + assert_eq!(header.current_linetype_name, "Continuous"); + assert_eq!(header.current_line_weight, 35); + assert!((header.current_entity_linetype_scale - 2.5).abs() < f64::EPSILON); + assert!( + (app.tabs[i] + .scene + .document + .current_entity_transparency() + .as_percent() + - 0.3) + .abs() + < 0.01 + ); + } + + #[test] + fn graphic_attributes_bulk_modes_and_remove_references_preserve_visible_values() { + let mut app = fresh(); + let i = app.active_tab; + { + let layer = app.tabs[i] + .scene + .document + .layers + .get_mut("0") + .expect("default layer"); + layer.color = acadrust::types::Color::Index(3); + layer.line_type = "Continuous".to_string(); + layer.line_weight = acadrust::types::LineWeight::Value(50); + layer.transparency = acadrust::types::Transparency::from_percent(0.25); + } + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(handle, false); + + let _ = app.on_graphic_attributes_set_all(false); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, acadrust::types::Color::ByBlock); + assert_eq!(common.linetype, "ByBlock"); + assert_eq!(common.line_weight, acadrust::types::LineWeight::ByBlock); + assert!(common.transparency.is_by_block()); + + let _ = app.on_graphic_attributes_set_all(true); + let _ = app.on_graphic_attributes_remove_references(); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, acadrust::types::Color::Index(3)); + assert_eq!(common.linetype, "Continuous"); + assert_eq!(common.line_weight, acadrust::types::LineWeight::Value(50)); + assert!((common.transparency.as_percent() - 0.25).abs() < 0.01); + } + + #[test] + fn graphic_attributes_create_layer_prompt_keeps_spaces_and_active_settings() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + let _ = app.on_line_color(acadrust::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); + let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); + + let _ = app.create_graphic_attributes_layer("Invalid\nLayer"); + assert!(app.tabs[i] + .scene + .document + .layers + .get("Invalid\nLayer") + .is_none()); + + let _ = app.update(ga(GraphicAttributesMsg::CreateLayer)); + assert_eq!( + app.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("GRAPHICATTRIBUTELAYER") + ); + let _ = app.update(Message::CommandInput("Graphic Settings".to_string())); + let _ = app.update(Message::CommandFinalize); + + let layer = app.tabs[i] + .scene + .document + .layers + .get("Graphic Settings") + .expect("created layer"); + assert_eq!(layer.color, acadrust::types::Color::Index(2)); + assert_eq!(layer.line_type, "Continuous"); + assert_eq!(layer.line_weight, acadrust::types::LineWeight::Value(35)); + assert!((layer.transparency.as_percent() - 0.3).abs() < 0.01); + } + + fn open_square(app: &mut OpenCADStudio) -> Handle { + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let handle = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(handle, false); + handle + } + + fn has_hatch(app: &OpenCADStudio) -> bool { + app.tabs[app.active_tab] + .scene + .document + .entities() + .any(|entity| matches!(entity, EntityType::Hatch(_))) + } + + fn is_closed(app: &OpenCADStudio, handle: Handle) -> bool { + matches!( + app.tabs[app.active_tab].scene.document.get_entity(handle), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + ) + } + + fn type_and_enter(app: &mut OpenCADStudio, input: &str) { + let _ = app.update(Message::CommandInput(input.to_string())); + let _ = app.update(Message::CommandSubmit); + } + + #[test] + fn closing_an_open_boundary_and_filling_it_is_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let boundary = open_square(&mut app); + let undo_before = app.tabs[i].history.undo_stack.len(); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(is_closed(&app, boundary)); + assert!(has_hatch(&app)); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let _ = app.update(Message::Undo); + assert!(!is_closed(&app, boundary)); + assert!(!has_hatch(&app)); + } + + #[test] + fn typed_answers_to_the_close_prompt() { + // Yes, typed and submitted with Enter. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "y"); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(is_closed(&app, boundary) && has_hatch(&app)); + + // No leaves the object open and unfilled. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "No"); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(!is_closed(&app, boundary) && !has_hatch(&app)); + + // Anything else keeps asking; a bare Enter takes the default Yes. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "maybe"); + assert!(app.graphic_attributes.pending_fill_close.is_some()); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let _ = app.update(Message::CommandFinalize); + assert!(is_closed(&app, boundary) && has_hatch(&app)); + + // Escape cancels the request. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let _ = app.update(Message::CommandEscape); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(!is_closed(&app, boundary) && !has_hatch(&app)); + } + + #[test] + fn linetype_scale_keeps_iso_pen_width_values() { + let mut app = fresh(); + let i = app.active_tab; + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(line, false); + for scale in [0.13, 0.18, 0.25, 0.35, 0.05] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(scale))); + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, scale); + } + // Spinner steps do not accumulate floating-point noise. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(0.25 + 0.1))); + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, 0.35); + for invalid in [0.0, -1.0, f64::INFINITY] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(invalid))); + } + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, 0.35); + } + + #[test] + fn graphic_attributes_starts_hidden_and_toggle_is_persisted() { + let mut app = OpenCADStudio::new_for_test(); + app.apply_config(crate::app::config::AppConfig::default()); + assert!(!app.show_graphic_attributes); + assert!(!app.ribbon.show_graphic_attributes); + + let _ = app.update(Message::ToggleGraphicAttributes); + assert!(app.show_graphic_attributes); + assert!(app.ribbon.show_graphic_attributes); + assert!(app.current_config().show_graphic_attributes); + + let _ = app.update(Message::ToggleGraphicAttributes); + assert!(!app.show_graphic_attributes); + assert!(!app.ribbon.show_graphic_attributes); + assert!(!app.current_config().show_graphic_attributes); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a88e44a38..82adcf37a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -95,6 +95,7 @@ mod context_menu; mod dialog; mod dynamic; mod file; +mod graphic_attributes; mod page_setup_import; mod style; pub(in crate::app) mod util; @@ -331,7 +332,8 @@ impl OpenCADStudio { { // Esc backs out of a pending ALIASEDIT draft first, mirroring // the shortcut editor's capture cancel; the next Esc closes. - if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add { + if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add + { return self.update(Message::AliasEditorDraftCancel); } if self.active_modal == Some(super::ModalKind::BlockDefinition) { @@ -393,12 +395,9 @@ impl OpenCADStudio { // a mouse move included — without allocating. #[cfg(not(target_arch = "wasm32"))] let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> { - app.tabs.get(app.active_tab).map(|tab| { - ( - tab.id, - tab.active_cmd.as_ref().map(|command| command.name()), - ) - }) + app.tabs + .get(app.active_tab) + .map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name()))) }; #[cfg(not(target_arch = "wasm32"))] let command_before = active_command(self); @@ -416,33 +415,9 @@ impl OpenCADStudio { } } self.refresh_gpu_status(); - if self.gradient_editor.as_ref().is_some_and(|editor| { + if self.graphic_attributes.tracks_selection() { let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); - selected.len() != editor.handles.len() - || selected - .iter() - .any(|handle| !editor.handles.contains(handle)) - }) { - self.gradient_editor = None; - } - if self.hatch_editor_handles.as_ref().is_some_and(|handles| { - let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); - selected.len() != handles.len() - || selected.iter().any(|handle| !handles.contains(handle)) - }) { - self.hatch_editor_handles = None; - } - if self - .pending_graphic_fill_close - .as_ref() - .is_some_and(|(_, _, handles)| { - let selected = - self.tabs[self.active_tab].scene.selected_handles_in_order(); - selected.len() != handles.len() - || selected.iter().any(|handle| !handles.contains(handle)) - }) - { - self.pending_graphic_fill_close = None; + self.graphic_attributes.retain_selection(&selected); } self.show_next_startup_modal(); self.sync_open_command_history(); @@ -457,19 +432,7 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| c.prompt()) - .or_else(|| { - self.pending_graphic_fill_close.as_ref().map(|(_, handles, _)| { - if handles.len() == 1 { - "The selected object is open. Close it before creating the fill? [Yes / No]" - .to_string() - } else { - format!( - "{} selected objects are open. Close them before creating the fill? [Yes / No]", - handles.len() - ) - } - }) - }) + .or_else(|| self.graphic_attributes.fill_close_prompt()) .or_else(|| self.pending_pick_label()); self.command_line.set_step_prompt(prompt); // Mirror the step's clickable options so they render as buttons (#304). @@ -478,7 +441,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.options()) .or_else(|| { - self.pending_graphic_fill_close.as_ref().map(|_| { + self.graphic_attributes.pending_fill_close.as_ref().map(|_| { vec![ crate::command::CmdOption::new("Yes", "Y"), crate::command::CmdOption::new("No", "N"), @@ -558,8 +521,9 @@ impl OpenCADStudio { Message::SpaceMouseWake => self.on_spacemouse_wake(), Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification), Message::SpaceMouseFrame(time) => { - self.spacemouse - .frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.); + self.spacemouse.frame( + time.saturating_duration_since(self.start).as_secs_f64() * 1000., + ); Task::none() } Message::SpaceMouseFocus(id, focused) => { @@ -611,8 +575,9 @@ impl OpenCADStudio { if response["ok"] == false { let code = response["code"].as_str().unwrap_or("failed"); let detail = response["error"].as_str().unwrap_or(""); - self.command_line - .push_error(&format!("automation {op}: {code} — {detail}")); + self.command_line.push_error(&format!( + "automation {op}: {code} — {detail}" + )); } else if matches!( response["status"].as_str(), Some("completed" | "cancelled" | "waiting_input") @@ -1213,23 +1178,19 @@ impl OpenCADStudio { Task::none() } - Message::ImageEmbedPick => Task::perform( - crate::io::pick_embedded_image_file(), - Message::ImageEmbedPickResult, - ), + Message::ImageEmbedPick => { + Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult) + } Message::ImageEmbedPickResult(Ok(image)) => { use crate::command::CadCommand; use crate::modules::draw::draw::raster_image::ImageCommand; - self.command_line.push_output( - crate::tf!( + self.command_line.push_output(crate::tf!( "IMAGEEMBED \"{name}\": {w}×{h} px (embedded)", name = image.name.as_str(), w = image.pixel_width, h = image.pixel_height, - ) - .as_ref(), - ); + ).as_ref()); let cmd = ImageCommand::new_embedded(image); let i = self.active_tab; self.command_line.push_info(&cmd.prompt()); @@ -1316,13 +1277,10 @@ impl OpenCADStudio { .insert(crate::io::font_repo::font_key(name)); } for (name, path) in &pairs { - self.command_line.push_output( - crate::tf!( + self.command_line.push_output(crate::tf!( "FONT Downloaded {name} → {path}", path = path.display() - ) - .as_ref(), - ); + ).as_ref()); } if !unavailable.is_empty() { self.command_line.push_info(crate::tf!( @@ -1338,16 +1296,14 @@ impl OpenCADStudio { if let Some(path) = path { return Task::done(Message::OpenExternal(path)); } - self.command_line.push_info( - crate::t!("Save and reopen the drawing to apply the new fonts.") - .as_ref(), - ); + self.command_line.push_info(crate::t!( + "Save and reopen the drawing to apply the new fonts." + ).as_ref()); } Err(e) => { // Keep the prompt open: the user can correct a custom // source or retry a transient network failure. - self.command_line - .push_error(crate::tf!("Font download failed: {e}").as_ref()); + self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); } } Task::none() @@ -2439,13 +2395,8 @@ impl OpenCADStudio { // finishes the step like Enter. (#304) self.command_line.input.clear(); self.command_line.close_history(); - if self.pending_graphic_fill_close.is_some() { - if kw.eq_ignore_ascii_case("Y") || kw.eq_ignore_ascii_case("YES") { - return self.on_graphic_fill_close_response(true); - } - if kw.eq_ignore_ascii_case("N") || kw.eq_ignore_ascii_case("NO") { - return self.on_graphic_fill_close_response(false); - } + if self.awaiting_fill_close_answer() { + return self.on_graphic_fill_close_input(&kw); } if kw.is_empty() { return self.feed_command(crate::command::StepInput::Enter); @@ -2543,8 +2494,7 @@ impl OpenCADStudio { use crate::app::config::DockSide; use crate::ui::dock::PanelId; if self.dock.location(PanelId::ExternalReferences).is_none() { - self.dock - .dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); + self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); } self.dock_expanded = Some(PanelId::ExternalReferences); self.refresh_xref_manager(); @@ -2682,13 +2632,16 @@ impl OpenCADStudio { }; let new_raw = path.to_string_lossy().into_owned(); self.push_undo_snapshot(i, "XREF-PATH"); - match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw) - { + match crate::io::xref::set_ref_path( + &mut self.tabs[i].scene.document, + key, + &new_raw, + ) { Ok(name) => { - self.command_line.push_output( - crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name) - .as_ref(), - ); + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); self.post_ref_op(i); } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -2698,8 +2651,7 @@ impl OpenCADStudio { } Message::XrefPathPickResult(Err(e)) => { if e != "Cancelled" { - self.command_line - .push_error(crate::tf!("XREF: {e}").as_ref()); + self.command_line.push_error(crate::tf!("XREF: {e}").as_ref()); } Task::none() } @@ -2724,11 +2676,9 @@ impl OpenCADStudio { if let Some(found) = entry.found_at.clone() { let is_dwg = found.to_ascii_lowercase().ends_with(".dwg") || found.to_ascii_lowercase().ends_with(".dxf"); - self.command_line - .push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); + self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); if is_dwg { - return self - .update(Message::OpenRecent(std::path::PathBuf::from(found))); + return self.update(Message::OpenRecent(std::path::PathBuf::from(found))); } else { #[cfg(not(target_arch = "wasm32"))] let _ = open::that_detached(&found); @@ -2764,10 +2714,7 @@ impl OpenCADStudio { self.xref_manager.row_change_path_open = false; let prefill = if let Some(entry) = self.xref_manager.entries.get(index) { let saved = &entry.saved_path; - if let Some(parent) = std::path::Path::new(saved) - .parent() - .and_then(|p| p.to_str()) - { + if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) { if !parent.is_empty() { format!("XREF Path Find \"{}\" ", parent) } else { @@ -3767,13 +3714,7 @@ impl OpenCADStudio { } Message::ViewportLeftPress => { - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; + self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); Task::batch(vec![sweep, self.on_viewport_left_press()]) } @@ -3781,13 +3722,7 @@ impl OpenCADStudio { Message::ViewportLeftRelease => self.on_viewport_left_release(), Message::ViewportRightPress => { - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; + self.graphic_attributes.open_menu = None; let i = self.active_tab; self.ribbon.close_dropdown(); // Shift+RMB: the one-shot snap override menu at the cursor — @@ -3890,13 +3825,7 @@ impl OpenCADStudio { } Message::ViewportMiddlePress => { - self.graphic_attribute_menu_open = false; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; + self.graphic_attributes.open_menu = None; self.on_viewport_middle_press() } @@ -4231,13 +4160,9 @@ impl OpenCADStudio { } Message::ToggleGraphicAttributes => { self.show_graphic_attributes ^= true; - self.ribbon - .set_graphic_attributes(self.show_graphic_attributes); + self.ribbon.set_graphic_attributes(self.show_graphic_attributes); if !self.show_graphic_attributes { - self.graphic_attributes_header_menu_open = false; - self.graphic_attribute_menu_open = false; - self.gradient_editor = None; - self.hatch_editor_handles = None; + self.graphic_attributes.close_popups(); } self.save_config(); Task::none() @@ -4433,9 +4358,7 @@ impl OpenCADStudio { continue; }; let current = ( - crate::scene::pe_url_of(entity) - .unwrap_or_default() - .to_owned(), + crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(), crate::scene::pe_url_description_of(entity) .unwrap_or_default() .to_owned(), @@ -4887,9 +4810,7 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.refresh_properties(); crate::entities::common::set_unit_context( - crate::entities::common::UnitContext::from_header( - &self.tabs[i].scene.document.header, - ), + crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header), ); Task::none() } @@ -5048,14 +4969,12 @@ impl OpenCADStudio { }; let name = state.name.trim().to_string(); if name.is_empty() { - state.error_message = - Some(crate::t!("Block name cannot be empty.").into_owned()); + state.error_message = Some(crate::t!("Block name cannot be empty.").into_owned()); return Task::none(); } if let Some(_ch) = state.invalid_name_char() { state.error_message = Some( - crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `") - .into_owned(), + crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(), ); return Task::none(); } @@ -5066,21 +4985,13 @@ impl OpenCADStudio { } if !state.objects_specify_onscreen && state.selected_handles.is_empty() { state.error_message = Some( - crate::t!( - "No objects selected. You must select objects to define a block." - ) + crate::t!("No objects selected. You must select objects to define a block.") .into_owned(), ); return Task::none(); } let i = self.active_tab; - if self.tabs[i] - .scene - .document - .block_records - .get(&name) - .is_some() - { + if self.tabs[i].scene.document.block_records.get(&name).is_some() { state.confirm_redefine = Some(name); return Task::none(); } @@ -5106,16 +5017,12 @@ impl OpenCADStudio { let current_path = std::path::Path::new(&state.file_path); let file_stem = current_path.file_stem().and_then(|s| s.to_str()); let is_default_or_block = file_stem == Some("new_block") - || state - .existing_blocks - .iter() - .any(|b| Some(b.as_str()) == file_stem); + || state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem); if is_default_or_block { let new_file_name = format!("{}.dwg", trimmed); if let Some(parent) = current_path.parent() { if !parent.as_os_str().is_empty() { - state.file_path = - parent.join(new_file_name).to_string_lossy().to_string(); + state.file_path = parent.join(new_file_name).to_string_lossy().to_string(); } else { state.file_path = new_file_name; } @@ -5236,19 +5143,12 @@ impl OpenCADStudio { WblockSourceMode::Block => { let name = state.block_name.trim(); if name.is_empty() { - state.error_message = Some( - crate::t!("Please select or enter a block name.").into_owned(), - ); + state.error_message = + Some(crate::t!("Please select or enter a block name.").into_owned()); return Task::none(); } let i = self.active_tab; - if self.tabs[i] - .scene - .document - .block_records - .get(name) - .is_none() - { + if self.tabs[i].scene.document.block_records.get(name).is_none() { state.error_message = Some( crate::tf!("Block \"{}\" does not exist in drawing.", name) .into_owned(), @@ -5372,7 +5272,10 @@ impl OpenCADStudio { } WblockSourceMode::Objects => { crate::modules::insert::wblock::extract_entities_to_doc_with_base( - &document, &handles, base_point, unit, + &document, + &handles, + base_point, + unit, ) .map_err(|e| e.to_string())? } @@ -5431,9 +5334,7 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.refresh_properties(); crate::entities::common::set_unit_context( - crate::entities::common::UnitContext::from_header( - &self.tabs[i].scene.document.header, - ), + crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header), ); let label = crate::modules::draw::units::linear_format_label(code); self.command_line @@ -5763,7 +5664,8 @@ impl OpenCADStudio { Task::none() } Message::AutoConstrainReset => { - self.auto_constrain_settings = super::settings::AutoConstrainSettings::default(); + self.auto_constrain_settings = + super::settings::AutoConstrainSettings::default(); self.auto_constrain_selected_row = 0; self.auto_constrain_distance_input = format!("{}", self.auto_constrain_settings.distance_tolerance); @@ -5802,7 +5704,8 @@ impl OpenCADStudio { self.auto_constrain_settings.distance_tolerance = distance; self.auto_constrain_settings.angle_tolerance_deg = angle; self.auto_constrain_settings.sanitize(); - self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_saved = + Some(self.auto_constrain_settings.clone()); self.persist_settings_if_changed(); if matches!(action, Message::AutoConstrainOk) { self.close_active_modal(); @@ -9590,312 +9493,7 @@ impl OpenCADStudio { Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), - Message::ToggleGraphicAttributesHeaderMenu => { - self.graphic_attributes_header_menu_open ^= true; - Task::none() - } - Message::CloseGraphicAttributesHeaderMenu => { - self.graphic_attributes_header_menu_open = false; - Task::none() - } - Message::GraphicAttributesSetAllByLayer => self.on_graphic_attributes_set_all(true), - Message::GraphicAttributesSetAllByBlock => self.on_graphic_attributes_set_all(false), - Message::GraphicAttributesRemoveReferences => { - self.on_graphic_attributes_remove_references() - } - Message::GraphicAttributesCreateLayer => { - self.graphic_attributes_header_menu_open = false; - let command = crate::command::FreeTextValuePromptCommand::new( - "GRAPHICATTRIBUTELAYER", - "Enter a name for the new layer:", - ); - self.command_line.push_info(&command.prompt()); - self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); - self.focus_cmd_input() - } - Message::ToggleGraphicAttributeDropdown => { - self.graphic_attribute_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseGraphicAttributeDropdown => { - self.graphic_attribute_menu_open = false; - Task::none() - } - Message::GraphicAttributeChanged(value) => self.on_graphic_attribute(value), - Message::ToggleLineColorDropdown => { - self.line_color_menu_open ^= true; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineColorDropdown => { - self.line_color_menu_open = false; - Task::none() - } - Message::LineColorChanged(color) => self.on_line_color(color), - Message::ToggleLineLinetypeMenu => { - self.line_linetype_menu_open ^= true; - self.line_color_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineLinetypeMenu => { - self.line_linetype_menu_open = false; - Task::none() - } - Message::LineLinetypeChanged(value) => self.on_line_linetype(value), - Message::ToggleLineLineweightMenu => { - self.line_lineweight_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineLineweightMenu => { - self.line_lineweight_menu_open = false; - Task::none() - } - Message::LineLineweightChanged(value) => self.on_line_lineweight(value), - Message::LineLinetypeScaleChanged(value) => self.on_line_linetype_scale(value), - Message::LineLinetypeScaleInput(value) => { - let value = value.trim().replace(',', "."); - match value.parse::() { - Ok(value) if value.is_finite() && value > 0.0 => { - self.on_line_linetype_scale(value) - } - _ => Task::none(), - } - } - Message::ToggleLineTransparencyDropdown => { - self.line_transparency_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseLineTransparencyDropdown => { - self.line_transparency_menu_open = false; - Task::none() - } - Message::LineTransparencyChanged(value) => self.on_line_transparency( - acadrust::types::Transparency::from_percent(f64::from(value) / 100.0), - ), - Message::LineTransparencyByLayer => { - self.on_line_transparency(acadrust::types::Transparency::BY_LAYER) - } - Message::LineTransparencyByBlock => { - self.on_line_transparency(acadrust::types::Transparency::BY_BLOCK) - } - Message::ToggleSolidFillColorDropdown => { - self.solid_fill_color_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.fill_transparency_menu_open = false; - Task::none() - } - Message::CloseSolidFillColorDropdown => { - self.solid_fill_color_menu_open = false; - Task::none() - } - Message::SolidFillColorChanged(color) => self.on_solid_fill_color(color), - Message::ToggleFillTransparencyDropdown => { - self.fill_transparency_menu_open ^= true; - self.line_color_menu_open = false; - self.line_linetype_menu_open = false; - self.line_lineweight_menu_open = false; - self.line_transparency_menu_open = false; - self.graphic_attribute_menu_open = false; - self.solid_fill_color_menu_open = false; - Task::none() - } - Message::CloseFillTransparencyDropdown => { - self.fill_transparency_menu_open = false; - Task::none() - } - Message::FillTransparencyChanged(value) => self.on_fill_transparency( - acadrust::types::Transparency::from_percent(f64::from(value) / 100.0), - ), - Message::FillTransparencyByLayer => { - self.on_fill_transparency(acadrust::types::Transparency::BY_LAYER) - } - Message::FillTransparencyByBlock => { - self.on_fill_transparency(acadrust::types::Transparency::BY_BLOCK) - } - Message::HatchEditorOpen => { - self.gradient_editor = None; - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - let current = handles.iter().find_map(|handle| { - let acadrust::EntityType::Hatch(hatch) = - self.tabs[i].scene.document.get_entity(*handle)? - else { - return None; - }; - Some(hatch.pattern.name.as_str()) - }); - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = current - .and_then(|current| { - crate::ui::properties::filtered_hatch_patterns("") - .iter() - .position(|entry| entry.name.eq_ignore_ascii_case(current)) - }) - .unwrap_or(0); - self.hatch_editor_handles = Some(selected); - iced::widget::operation::focus(iced::widget::Id::new( - "graphic-hatch-pattern-search", - )) - } - Message::HatchEditorClose => { - self.hatch_editor_handles = None; - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = 0; - Task::none() - } - Message::GraphicHatchPatternSearchChanged(search) => { - self.graphic_hatch_pattern_search = search; - self.graphic_hatch_pattern_focus = 0; - Task::none() - } - Message::GraphicHatchPatternFocus(index) => { - let len = crate::ui::properties::filtered_hatch_patterns( - &self.graphic_hatch_pattern_search, - ) - .len(); - if index < len { - self.graphic_hatch_pattern_focus = index; - } - Task::none() - } - Message::GraphicHatchPatternChanged(name) => { - let i = self.active_tab; - let selected = self.tabs[i].scene.selected_handles_in_order(); - let handles = crate::ui::window::graphic_attributes::fill_handles( - &self.tabs[i].scene.document, - &selected, - ); - self.hatch_editor_handles = None; - self.graphic_hatch_pattern_search.clear(); - self.graphic_hatch_pattern_focus = 0; - self.apply_hatch_pattern(i, &handles, name) - } - Message::GraphicHatchPatternConfirm => { - let name = crate::ui::properties::filtered_hatch_patterns( - &self.graphic_hatch_pattern_search, - ) - .get(self.graphic_hatch_pattern_focus) - .map(|entry| entry.name.clone()); - if let Some(name) = name { - self.update(Message::GraphicHatchPatternChanged(name)) - } else { - Task::none() - } - } - Message::GradientEditorOpen => { - self.hatch_editor_handles = None; - self.open_gradient_editor(); - Task::none() - } - Message::GradientColorModeChanged(mode) => { - if let Some(s) = &mut self.gradient_editor { - s.color_mode = mode; - } - Task::none() - } - Message::GradientColorToggle(index) => { - if let Some(s) = &mut self.gradient_editor { - s.color_open = if s.color_open == Some(index) { - None - } else { - Some(index) - }; - } - Task::none() - } - Message::GradientColorChanged(index, color) => { - if let Some(s) = &mut self.gradient_editor { - if index == 1 { - s.color_1 = color - } else { - s.color_2 = color - }; - s.color_open = None; - } - Task::none() - } - Message::GradientShadeTintChanged(value) => { - if let Some(s) = &mut self.gradient_editor { - s.shade_tint = value.clamp(0.0, 1.0); - } - Task::none() - } - Message::GradientTypeChanged(kind) => { - if let Some(s) = &mut self.gradient_editor { - s.kind = kind; - if kind == crate::scene::model::hatch_model::GradientKind::Linear { - s.inverted = false; - } - } - Task::none() - } - Message::GradientInvertToggled => { - if let Some(s) = &mut self.gradient_editor { - if s.kind != crate::scene::model::hatch_model::GradientKind::Linear { - s.inverted ^= true; - } - } - Task::none() - } - Message::GradientAngleChanged(value) => { - if let Some(s) = &mut self.gradient_editor { - s.angle = value; - } - Task::none() - } - Message::GradientAngleReset => { - if let Some(s) = &mut self.gradient_editor { - s.angle = "0.0°".into(); - } - Task::none() - } - Message::GradientCenteredChanged(value) => { - if let Some(s) = &mut self.gradient_editor { - s.centered = value; - } - Task::none() - } - Message::GradientApply => self.apply_gradient_editor(), - Message::GradientCancel => { - self.gradient_editor = None; - Task::none() - } + Message::GraphicAttributes(m) => self.on_graphic_attributes(m), Message::Dock(m) => self.on_dock(m), Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { @@ -11032,14 +10630,12 @@ impl OpenCADStudio { Task::none() } Message::OpenColorWindow(target, color) => { - match target { - crate::app::ColorPickTarget::GraphicAttributesLine => { - self.line_color_menu_open = false; - } - crate::app::ColorPickTarget::GraphicAttributesSolid => { - self.solid_fill_color_menu_open = false; - } - _ => {} + if matches!( + target, + crate::app::ColorPickTarget::GraphicAttributesLine + | crate::app::ColorPickTarget::GraphicAttributesSolid + ) { + self.graphic_attributes.open_menu = None; } self.color_pick_target = Some((target, color)); @@ -11233,30 +10829,6 @@ mod prop_pointer_tests { } } -#[cfg(test)] -mod graphic_attributes_visibility_tests { - use super::Message; - use crate::app::OpenCADStudio; - - #[test] - fn graphic_attributes_starts_hidden_and_toggle_is_persisted() { - let mut app = OpenCADStudio::new_for_test(); - app.apply_config(crate::app::config::AppConfig::default()); - assert!(!app.show_graphic_attributes); - assert!(!app.ribbon.show_graphic_attributes); - - let _ = app.update(Message::ToggleGraphicAttributes); - assert!(app.show_graphic_attributes); - assert!(app.ribbon.show_graphic_attributes); - assert!(app.current_config().show_graphic_attributes); - - let _ = app.update(Message::ToggleGraphicAttributes); - assert!(!app.show_graphic_attributes); - assert!(!app.ribbon.show_graphic_attributes); - assert!(!app.current_config().show_graphic_attributes); - } -} - #[cfg(test)] mod free_text_entry_tests { //! The free-form text prompts (table cell content, TEXT / MTEXT bodies) diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 94e81be06..d9ea9aa33 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -4,12 +4,14 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::model::object::GripApply; +use crate::ui::window::graphic_attributes::GraphicAttributesMsg; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; +use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -19,6 +21,7 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; + impl OpenCADStudio { pub(in crate::app) fn mlstyle_mut( &mut self, @@ -105,10 +108,7 @@ impl OpenCADStudio { } } - pub(in crate::app) fn tablestyle_mut( - &mut self, - tab: usize, - ) -> Option<&mut acadrust::objects::TableStyle> { + pub(in crate::app) fn tablestyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::TableStyle> { use acadrust::objects::ObjectType; let name = self.tablestyle_selected.clone(); self.tabs[tab] @@ -212,10 +212,7 @@ impl OpenCADStudio { /// Mutable access to the currently selected multileader style. - pub(in crate::app) fn mleaderstyle_mut( - &mut self, - tab: usize, - ) -> Option<&mut acadrust::objects::MultiLeaderStyle> { + pub(in crate::app) fn mleaderstyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::MultiLeaderStyle> { use acadrust::objects::ObjectType; let name = self.mleaderstyle_selected.clone(); self.tabs[tab] @@ -823,8 +820,7 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_edit( - &mut self, field: &'static str, value: String,) -> Task { + pub(super) fn on_mleader_style_edit(&mut self, field: &'static str, value: String) -> Task { self.mls_color_open = None; match field { "landing_distance" => self.mls_landing_distance = value, @@ -851,8 +847,7 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_set_enum( - &mut self, field: &'static str, value: String,) -> Task { + pub(super) fn on_mleader_style_set_enum(&mut self, field: &'static str, value: String) -> Task { use acadrust::objects::{ BlockContentConnectionType, LeaderContentType, LeaderDrawOrderType, MultiLeaderDrawOrderType, MultiLeaderPathType, TextAlignmentType, @@ -872,8 +867,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { "BottomOfTopLineUnderlineTopLine" => { TextAttachmentType::BottomOfTopLineUnderlineTopLine } - "BottomOfTopLineUnderlineAll" => - TextAttachmentType::BottomOfTopLineUnderlineAll, + "BottomOfTopLineUnderlineAll" => { + TextAttachmentType::BottomOfTopLineUnderlineAll + } "CenterOfText" => TextAttachmentType::CenterOfText, "CenterOfTextOverline" => TextAttachmentType::CenterOfTextOverline, _ => TextAttachmentType::MiddleOfTopLine, @@ -899,8 +895,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { } "text_angle_type" => { s.text_angle_type = match value.as_str() { - "ParallelToLastLeaderLine" => - TextAngleType::ParallelToLastLeaderLine, + "ParallelToLastLeaderLine" => { + TextAngleType::ParallelToLastLeaderLine + } "Optimized" => TextAngleType::Optimized, _ => TextAngleType::Horizontal, }; @@ -946,8 +943,7 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_set_handle( - &mut self, field: &'static str, value: String,) -> Task { + pub(super) fn on_mleader_style_set_handle(&mut self, field: &'static str, value: String) -> Task { let i = self.active_tab; let doc = &self.tabs[i].scene.document; let handle: Option = if value == "None" @@ -1135,36 +1131,42 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { let s = crate::ui::color_select::color_to_aci_string(color); let edit = match self.color_pick_target.take().map(|(target, _)| target) { Some(crate::app::ColorPickTarget::Gradient(index)) => { - Some(Message::GradientColorChanged(index, color)) + Some(Message::GraphicAttributes(GraphicAttributesMsg::Gradient( + crate::ui::window::gradient_editor::GradientMsg::Color(index, color), + ))) } Some(crate::app::ColorPickTarget::GraphicAttributesLine) => { - Some(Message::LineColorChanged(color)) - } - Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { - Some(Message::SolidFillColorChanged(color)) + Some(Message::GraphicAttributes(GraphicAttributesMsg::LineColor(color))) + } + Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => { + Some(Message::GraphicAttributes(GraphicAttributesMsg::FillColor(color))) } Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)), Some(crate::app::ColorPickTarget::MLeader(f)) => { Some(Message::MLeaderStyleEdit { field: f, value: s }) } - Some(crate::app::ColorPickTarget::Table(r, f)) => + Some(crate::app::ColorPickTarget::Table(r, f)) => { Some(Message::TableStyleCellEdit { row: r, field: f, value: s, - }), - Some(crate::app::ColorPickTarget::Properties) => - Some(Message::PropColorChanged(color)), + }) + } + Some(crate::app::ColorPickTarget::Properties) => { + Some(Message::PropColorChanged(color)) + } Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } Some(crate::app::ColorPickTarget::PropertiesField(field)) => { Some(Message::PropColorFieldChanged { field, color }) } - Some(crate::app::ColorPickTarget::MText) => - Some(Message::MTextColorChanged(color)), - Some(crate::app::ColorPickTarget::Ribbon) => - Some(Message::RibbonColorChanged(color)), + Some(crate::app::ColorPickTarget::MText) => { + Some(Message::MTextColorChanged(color)) + } + Some(crate::app::ColorPickTarget::Ribbon) => { + Some(Message::RibbonColorChanged(color)) + } Some(crate::app::ColorPickTarget::Layer(idx)) => { self.tabs[self.active_tab].layers.selected = Some(idx); Some(Message::LayerColorSet(color)) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index add93fa4d..06b8cab97 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -613,10 +613,7 @@ bg={bg_ms:.1}ms n={view_count}" let control_polygon = tab.selected_handle.and_then(|handle| { let spline = match tab.scene.document.get_entity(handle) { Some(acadrust::EntityType::Spline(spline)) - if crate::entities::spline::shows_control_vertices(spline) => - { - spline - } + if crate::entities::spline::shows_control_vertices(spline) => spline, _ => return None, }; if tab @@ -1112,12 +1109,8 @@ bg={bg_ms:.1}ms n={view_count}" (None, _) if rectangle_values.is_some() => { let (width, height) = rectangle_values.unwrap(); match f.role { - crate::command::DynRole::Width => { - crate::entities::common::format_length(width) - } - crate::command::DynRole::Height => { - crate::entities::common::format_length(height) - } + crate::command::DynRole::Width => crate::entities::common::format_length(width), + crate::command::DynRole::Height => crate::entities::common::format_length(height), _ => String::new(), } } @@ -2750,9 +2743,7 @@ impl OpenCADStudio { } else { Subscription::none() }, - self.spacemouse - .subscription() - .map(|_| Message::SpaceMouseWake), + self.spacemouse.subscription().map(|_| Message::SpaceMouseWake), crate::input::trackpad::subscription().map(Message::TrackpadPinch), event::listen_with(|event, _, id| match event { iced::Event::Window(window::Event::Focused) => { @@ -2940,49 +2931,31 @@ impl OpenCADStudio { let panel: Element<'_, Message> = match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), crate::ui::dock::PanelId::GraphicAttributes => { - let draw_depth = tab.scene.draw_depth_map(); crate::ui::window::graphic_attributes::view( - &tab.scene.document, + &self.graphic_attributes, + &tab.scene, &tab.properties, - &tab.scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect::>(), - draw_depth.as_ref(), width, auto_collapse, side, - self.graphic_attributes_header_menu_open, - self.graphic_attribute_menu_open, - self.line_color_menu_open, - self.line_linetype_menu_open, - self.line_lineweight_menu_open, - self.line_transparency_menu_open, - self.solid_fill_color_menu_open, - self.fill_transparency_menu_open, - self.hatch_editor_handles.is_some(), - &self.graphic_hatch_pattern_search, - self.graphic_hatch_pattern_focus, - self.gradient_editor.as_ref(), matches!( - self.color_pick_target.as_ref().map(|(target, _)| target), - Some(crate::app::ColorPickTarget::Gradient(_)) + self.color_pick_target, + Some((crate::app::ColorPickTarget::Gradient(_), _)) ), ) } crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) } - crate::ui::dock::PanelId::ExternalReferences => { - self.xref_manager - .view(width, auto_collapse, tab.xref_missing, &tab.scene.document) - } + crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( + width, + auto_collapse, + tab.xref_missing, + &tab.scene.document, + ), crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( &tab.scene.document, - tab.sketch_session - .as_ref() - .map(|session| session.name.as_str()), + tab.sketch_session.as_ref().map(|session| session.name.as_str()), width, auto_collapse, ), diff --git a/src/command.rs b/src/command.rs index 5fb96016c..bfa4c19eb 100644 --- a/src/command.rs +++ b/src/command.rs @@ -308,10 +308,6 @@ pub struct ValuePromptCommand { prompt: &'static str, } -fn dispatch_prompt_value(name: &str, text: &str) -> CmdResult { - CmdResult::Dispatch(format!("{name} {}", text.trim())) -} - impl ValuePromptCommand { pub fn new(name: &'static str, prompt: &'static str) -> Self { Self { name, prompt } @@ -332,7 +328,12 @@ impl CadCommand for ValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - Some(dispatch_prompt_value(self.name, text)) + let t = text.trim(); + if t.is_empty() { + Some(CmdResult::Dispatch(format!("{} ", self.name))) + } else { + Some(CmdResult::Dispatch(format!("{} {t}", self.name))) + } } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -342,7 +343,7 @@ impl CadCommand for ValuePromptCommand { fn on_enter(&mut self) -> CmdResult { // Bare Enter → report the current value via the inline handler. - dispatch_prompt_value(self.name, "") + CmdResult::Dispatch(format!("{} ", self.name)) } } @@ -374,7 +375,7 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - Some(dispatch_prompt_value(self.name, text)) + Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim()))) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -382,7 +383,7 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_enter(&mut self) -> CmdResult { - dispatch_prompt_value(self.name, "") + CmdResult::Dispatch(format!("{} ", self.name)) } } @@ -3078,20 +3079,6 @@ mod constraint_registry_tests { mod tests { use super::*; - #[test] - fn prompt_dispatch_trims_values_and_preserves_empty_argument() { - let CmdResult::Dispatch(value) = dispatch_prompt_value("TEST", " value with spaces ") - else { - panic!("prompt input should dispatch a command"); - }; - assert_eq!(value, "TEST value with spaces"); - - let CmdResult::Dispatch(value) = dispatch_prompt_value("TEST", " ") else { - panic!("empty prompt input should dispatch a command"); - }; - assert_eq!(value, "TEST "); - } - #[test] fn test_mid2point_command() { let mut cmd = Mid2PointCommand::new(); diff --git a/src/scene/entity.rs b/src/scene/entity.rs index c43ba3cd9..ba3cb4af1 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2004,11 +2004,9 @@ impl Scene { let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); - if path - .edges - .iter() - .any(|edge| matches!(edge, acadrust::entities::BoundaryEdge::Spline(_))) - { + if path.edges.iter().any(|edge| { + matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) + }) { spline_paths.push((path, local_ring, range)); } boundary_exterior.push(depth == 0); @@ -2057,14 +2055,7 @@ impl Scene { .colors .get(i) .and_then(|e| e.color.rgb()) - .map(|(r, g, b)| { - [ - r as f32 / 255.0, - g as f32 / 255.0, - b as f32 / 255.0, - color[3], - ] - }) + .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, color[3]]) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); @@ -2221,21 +2212,13 @@ impl Scene { } let project = |point: [f64; 2]| { let [x, y] = to_xy(point[0], point[1]); - [ - (x - world_origin[0]) as f32 as f64, - (y - world_origin[1]) as f32 as f64, - ] + [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] }; for (path, ring, range) in spline_paths.into_iter().rev() { if range.end > boundary.len() { continue; } - let curves = || { - path.edges - .iter() - .filter_map(crate::entities::hatch::edge_curve) - .collect() - }; + let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); if let Some(refined) = cadkernel::geom2d::refine_spline_boundary(&ring, curves, &project) { @@ -2243,10 +2226,7 @@ impl Scene { continue; } boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); - local_boundary.splice( - range, - refined.into_iter().map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); } } diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs index 0210d6489..85fd55f38 100644 --- a/src/scene/hatch_boundary/tests.rs +++ b/src/scene/hatch_boundary/tests.rs @@ -221,7 +221,8 @@ fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { |p| p ) .is_none()); - assert!(refine_spline_boundary(&vec![[0.0; 2]; 513], + assert!(refine_spline_boundary( + &vec![[0.0; 2]; 513], || panic!("size cap ignored"), |p| p ) diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 56e300ced..cfcc3f469 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -362,8 +362,9 @@ impl HatchModel { self.pattern_segments_with_dot_length(Some(0.0)) } - fn pattern_segments_with_dot_length(&self, - plot_dot_length: Option + fn pattern_segments_with_dot_length( + &self, + plot_dot_length: Option, ) -> Vec<[[f64; 2]; 2]> { let HatchPattern::Pattern(families) = &self.pattern else { return Vec::new(); diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 4cfecd2d5..0f60f9223 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -2,7 +2,8 @@ use super::*; impl Scene { - pub(super) fn paper_viewport_handles(&self + pub(super) fn paper_viewport_handles( + &self, ) -> (Handle, Handle, Arc>) { { let cache = self.paper_viewport_cache.borrow(); @@ -11,7 +12,7 @@ impl Scene { return ( cache.layout_block, cache.sheet, - Arc::clone(&cache.content) + Arc::clone(&cache.content), ); } } @@ -122,8 +123,7 @@ impl Scene { ) -> Vec { if self.current_layout == "Model" { let tiles = self.model_tiles.borrow(); - let active = self.active_model_tile.get() - .min(tiles.len().saturating_sub(1)); + let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); return tiles .iter() .enumerate() @@ -298,7 +298,9 @@ impl Scene { hatches.push(sheet); } hatches.extend( - self.paper_canvas_hatches(tint_selected).iter().cloned() + self.paper_canvas_hatches(tint_selected) + .iter() + .cloned(), ); let hatches = Arc::new(hatches); let wipeouts = self.paper_canvas_wipeouts(); @@ -375,8 +377,9 @@ impl Scene { /// Physical sheet bounds in canvas pixels. Uses the same forced top-down /// paper transform as the GPU sheet viewport, ignoring stored camera twist. - pub fn paper_sheet_screen_rect(&self, - canvas_px: (f32, f32) + pub fn paper_sheet_screen_rect( + &self, + canvas_px: (f32, f32), ) -> Option { let ((x0, y0), (x1, y1)) = self.paper_limits()?; let (canvas_w, canvas_h) = canvas_px; @@ -474,13 +477,13 @@ impl Scene { ) .is_some() => { - Self::hatch_model_from_dxf(dxf, model.color).unwrap_or_else(|| model.clone()) + Self::hatch_model_from_dxf(dxf, model.color) + .unwrap_or_else(|| model.clone()) } _ => model.clone(), }; let style = self.render_style(entity); - if matches!( - m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + if matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { m.set_alpha(style.0[3]); } else { m.color = style.0; @@ -601,15 +604,13 @@ impl Scene { ) .is_some() => { - Self::hatch_model_from_dxf(dxf, model.color).unwrap_or_else(|| model.clone()) + Self::hatch_model_from_dxf(dxf, model.color) + .unwrap_or_else(|| model.clone()) } _ => model.clone(), }; let style = self.render_style(entity); - if matches!( - hatch.pattern, - model::hatch_model::HatchPattern::Gradient { .. } - ) { + if matches!(hatch.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { hatch.set_alpha(style.0[3]); } else { hatch.color = style.0; @@ -722,9 +723,9 @@ impl Scene { // from a big symbol library) has viewports legitimately aimed at that // second cluster; testing only the dense one wrongly auto-fits them onto // the library. Fall back to the cluster box when no extents are known. - let full_bounds = self.model_space_extents() - .map(|(mn, mx)| - (mn.x as f64, mn.y as f64, mx.x as f64, mx.y as f64)); + let full_bounds = self.model_space_extents().map(|(mn, mx)| { + (mn.x as f64, mn.y as f64, mx.x as f64, mx.y as f64) + }); // Absolute drawing centre. Geometry now reaches the scene at absolute // (UTM) coordinates — the old code centred the overlap test and the // auto-fit on the origin, which was right only while world_offset @@ -741,8 +742,9 @@ impl Scene { (self.local_center[0], self.local_center[1]) } else { self.model_space_extents() - .map(|(mn, mx)| - (((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)) + .map(|(mn, mx)| { + (((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64) + }) .unwrap_or((0.0, 0.0)) }; diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 2991d9e51..caf96dd62 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -273,7 +273,10 @@ mod tests { fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); - assert_eq!(state.location(PanelId::GraphicAttributes), Some((DockSide::Left, 1))); + assert_eq!( + state.location(PanelId::GraphicAttributes), + Some((DockSide::Left, 1)) + ); assert_eq!( state.location(PanelId::BlockPalette), Some((DockSide::Right, 0)) @@ -373,4 +376,4 @@ mod tests { assert_eq!(drop_index(10.0, 2, 900.0), 0); assert_eq!(drop_index(890.0, 2, 900.0), 2); } -} +} \ No newline at end of file diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 0b85eb8c3..f0a86db38 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -10,7 +10,6 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::{fmt, sync::Arc}; -use crate::t; use crate::ui::ROW_H; use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::Handle; @@ -21,6 +20,7 @@ use iced::widget::{ use iced::{ mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme, }; +use crate::t; // ── Row-height-derived constants ───────────────────────────────────────── const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px @@ -205,7 +205,7 @@ fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) } } -pub(crate) fn hatch_pattern_preview( +fn hatch_pattern_preview( pattern: crate::scene::model::hatch_model::HatchPattern, height: f32, ) -> Element<'static, Message> { @@ -213,9 +213,9 @@ pub(crate) fn hatch_pattern_preview( pattern, padding: 4.0, }) - .width(Length::Fill) - .height(height) - .into() + .width(Length::Fill) + .height(height) + .into() } pub(crate) fn compact_hatch_pattern_preview( @@ -237,8 +237,7 @@ fn hatch_pattern_matches( let query = search.trim(); query.is_empty() || entry.name.to_lowercase().contains(&query.to_lowercase()) - || entry.description.to_lowercase() - .contains(&query.to_lowercase()) + || entry.description.to_lowercase().contains(&query.to_lowercase()) } pub(crate) fn filtered_hatch_patterns( @@ -412,7 +411,7 @@ impl FieldKey { } /// Text-input widget id for one column of a named-parameter row. -pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField,) -> iced::widget::Id { +pub fn prop_param_field_id(index: usize, field: crate::ui::window::named_parameters::ParamField) -> iced::widget::Id { use crate::ui::window::named_parameters::ParamField; let suffix = match field { ParamField::Name => "name", @@ -461,7 +460,7 @@ pub fn active_key_focused( /// its field key in O(1) instead of re-scanning the sections, enabling /// select-all-on-focus for both text inputs and edit-choices (e.g. transparency). pub fn build_field_key_map( - sections: &[PropSection] + sections: &[PropSection], ) -> HashMap { let mut map = HashMap::default(); for section in sections { @@ -675,7 +674,7 @@ impl PropertiesPanel { crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties,))) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { @@ -700,7 +699,9 @@ impl PropertiesPanel { let close = tooltip( close, text(t!("Close")).size(10), - tooltip::Position::Bottom).gap(4); + tooltip::Position::Bottom, + ) + .gap(4); let header = mouse_area( container( @@ -715,7 +716,7 @@ impl PropertiesPanel { ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.weak.color + theme.palette().background.weak.color, )), ..Default::default() }) @@ -882,7 +883,9 @@ impl PropertiesPanel { let hdr = container( row![ title, - toggle,].align_y(iced::Center) + toggle, + ] + .align_y(iced::Center), ) .style(|theme: &Theme| { let palette = theme.palette(); @@ -946,8 +949,9 @@ impl PropertiesPanel { label: &'a str, ) -> Element<'a, Message> { match &prop.value { - PropValue::ColorChoice(color) => - self.render_color_row(label, prop.field, *color, None), + PropValue::ColorChoice(color) => { + self.render_color_row(label, prop.field, *color, None) + } PropValue::NamedColorChoice { color, name } => { self.render_color_row(label, prop.field, *color, Some(name)) } @@ -957,8 +961,9 @@ impl PropertiesPanel { PropValue::FieldLwChoice { field, value } => { self.render_field_lw_row(label, field, *value) } - PropValue::FieldLwVaries { field } => - self.render_field_lw_varies_row(label, field), + PropValue::FieldLwVaries { field } => { + self.render_field_lw_varies_row(label, field) + } PropValue::LwVaries => self.render_lw_varies_row(label), PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt), PropValue::Choice { selected, options } => { @@ -980,13 +985,16 @@ impl PropertiesPanel { PropValue::ReadOnlyWithTooltip { value, tooltip } => { render_ro_with_tooltip_row(label, value, tooltip) } - PropValue::HatchPatternChoice(current) => - self.render_hatch_pattern_row(label, current), + PropValue::HatchPatternChoice(current) => { + self.render_hatch_pattern_row(label, current) + } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), - PropValue::EntityLink { id, handles, conflicting, } => - render_entity_link_row(label, *id, handles.clone(), *conflicting), - PropValue::ParamRow { index, name, formula, resolved, } => - self.render_param_row(*index, name, formula, resolved), + PropValue::EntityLink { id, handles, conflicting } => { + render_entity_link_row(label, *id, handles.clone(), *conflicting) + } + PropValue::ParamRow { index, name, formula, resolved } => { + self.render_param_row(*index, name, formula, resolved) + } PropValue::ParamAddRow => render_param_add_row(), PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*value), } @@ -1047,7 +1055,7 @@ impl PropertiesPanel { // (#415). Message::OpenColorWindow( crate::app::ColorPickTarget::PropertiesBg, - color + color, ), ); return prop_row_widget(label, selector); @@ -1123,7 +1131,7 @@ impl PropertiesPanel { Message::PropColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::Properties, - color + color, ), ); prop_row_widget(label, selector) @@ -1141,7 +1149,7 @@ impl PropertiesPanel { Message::PropColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::Properties, - AcadColor::ByLayer + AcadColor::ByLayer, ), ); prop_row_widget(label, selector) @@ -1531,28 +1539,24 @@ impl PropertiesPanel { use crate::ui::window::named_parameters::ParamField; let name_key = FieldKey::Param(index, ParamField::Name); - let name_display = self.edit_buf.get(&name_key) - .map(|s| s.as_str()) - .unwrap_or(name); + let name_display = self.edit_buf.get(&name_key).map(|s| s.as_str()).unwrap_or(name); let name_active = self.active_field.as_ref() == Some(&name_key); let name_input = text_input("name", name_display) .id(name_key.widget_id()) - .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v, }) - .on_submit(Message::PropParamCommit { index, field: ParamField::Name, }) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Name, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Name }) .size(FONT_SZ) .style(text_input_style) .padding([3, 6]) .width(Length::FillPortion(3)); let formula_key = FieldKey::Param(index, ParamField::Formula); - let formula_display = self.edit_buf.get(&formula_key) - .map(|s| s.as_str()) - .unwrap_or(formula); + let formula_display = self.edit_buf.get(&formula_key).map(|s| s.as_str()).unwrap_or(formula); let formula_active = self.active_field.as_ref() == Some(&formula_key); let formula_input = text_input("formula", formula_display) .id(formula_key.widget_id()) - .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v, }) - .on_submit(Message::PropParamCommit { index, field: ParamField::Formula, }) + .on_input(move |v| Message::PropParamInput { index, field: ParamField::Formula, value: v }) + .on_submit(Message::PropParamCommit { index, field: ParamField::Formula }) .size(FONT_SZ) .style(text_input_style) .padding([3, 6]) @@ -1563,10 +1567,9 @@ impl PropertiesPanel { Err(_) => ("—".to_string(), true), }; let value_label = container( - text(value_text).size(FONT_SZ).style(move |theme: &Theme| { iced::widget::text::Style { + text(value_text).size(FONT_SZ).style(move |theme: &Theme| iced::widget::text::Style { color: is_error.then_some(theme.palette().danger.base.color), - } - }) + }), ) .width(Length::FillPortion(2)) .align_x(iced::Right); @@ -1584,15 +1587,13 @@ impl PropertiesPanel { Background::Color(theme.palette().background.base.color) } }; - let content = container(row![name_input, formula_input, value_label, delete_btn] - .spacing(4) - .align_y(iced::Center),) + let content = container(row![name_input, formula_input, value_label, delete_btn].spacing(4).align_y(iced::Center)) .style(move |theme: &Theme| container::Style { background: Some(bg(theme)), ..Default::default() }) .padding([2, 6]) .width(Length::Fill); if let Err(err) = resolved { - tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top,) + tooltip(content, text(err.as_str()).size(FONT_SZ), tooltip::Position::Top) .gap(4.0) .padding(6.0) .style(|theme: &Theme| { @@ -1600,7 +1601,7 @@ impl PropertiesPanel { container::Style { background: Some(Background::Color(palette.danger.weak.color)), text_color: Some(palette.danger.weak.text), - border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into(), }, + border: Border { color: palette.danger.base.color, width: 1.0, radius: 4.0.into() }, ..Default::default() } }) @@ -1658,16 +1659,22 @@ impl PropertiesPanel { return prop_row_widget(label, head.into()); } - let popup = hatch_pattern_picker_content( &self.hatch_pattern_search, + let popup = hatch_pattern_picker_content( + &self.hatch_pattern_search, self.hatch_pattern_focus, - current,"hatch-pattern-search",Message::PropHatchPatternSearchChanged,Message::PropHatchPatternConfirm, + current, + "hatch-pattern-search", + Message::PropHatchPatternSearchChanged, + Message::PropHatchPatternConfirm, Message::PropHatchPatternFocus, Message::PropHatchPatternChanged, ); prop_row_widget( - label,crate::ui::color_select::drop_down_below( - head.into(), popup, + label, + crate::ui::color_select::drop_down_below( + head.into(), + popup, Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), @@ -1723,10 +1730,11 @@ fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Elem }; let btn = button( - text(btn_label).size(FONT_SZ).style(move |theme: &Theme| { iced::widget::text::Style { + text(btn_label) + .size(FONT_SZ) + .style(move |theme: &Theme| iced::widget::text::Style { color: value.then_some(theme.palette().warning.base.color), - } - }) + }), ) .on_press(Message::PropBoolToggle(field)) .style(move |theme: &Theme, status| { @@ -1869,7 +1877,7 @@ fn render_group_row( let label_col = container(label_btn) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.weakest.color + theme.palette().background.weakest.color, )), ..Default::default() }) @@ -1882,7 +1890,7 @@ fn render_group_row( let value_col = container(value_field) .style(|theme: &Theme| container::Style { background: Some(Background::Color( - theme.palette().background.base.color + theme.palette().background.base.color, )), ..Default::default() }) @@ -1910,7 +1918,7 @@ fn render_group_row( } fn render_annotative_scale_row<'a>( label: &'a str, - value: &'a str + value: &'a str, ) -> Element<'a, Message> { let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); @@ -1956,7 +1964,7 @@ fn render_ro_with_tooltip_row<'a>( tooltip_text: &'a str, ) -> Element<'a, Message> { let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); - let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top,) + let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top) .gap(4.0) .padding(6.0) .style(|theme: &Theme| { @@ -2004,7 +2012,7 @@ fn render_entity_link_row<'a>( }; button::Style { background: Some(Background::Color(pair.color)), - border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, text_color: pair.text, ..Default::default() } @@ -2027,10 +2035,7 @@ fn render_entity_link_row<'a>( /// — avoids a separate "pending new row" concept, since every row always /// reflects a real committed table entry. fn render_param_add_row<'a>() -> Element<'a, Message> { - let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ) - ] - .spacing(6) - .align_y(iced::Center),) + let btn = button(row![text("+").size(FONT_SZ), text(t!("Add parameter").into_owned()).size(FONT_SZ)].spacing(6).align_y(iced::Center)) .on_press(Message::PropParamAddNew) .style(button::text) .padding([4, 8]) @@ -2063,18 +2068,14 @@ fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> }; button::Style { background: Some(Background::Color(pair.color)), - border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, text_color: pair.text, ..Default::default() } }) .padding([4, 8]) .width(Length::Fill); - container(row![text(t!("Values").into_owned()) - .size(FONT_SZ) - .width(Length::Fill), btn - ] - .align_y(iced::Center),) + container(row![text(t!("Values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center)) .width(Length::Fill) .into() } @@ -2224,9 +2225,7 @@ fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::St } } -pub(crate) - -fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { +pub(crate) fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { text_input_style(theme, status) } @@ -2250,7 +2249,7 @@ mod tests { }; fn sample_sections() -> Vec { - use crate::scene::model::object::{PropValue,Property}; + use crate::scene::model::object::{Property, PropValue}; vec![ crate::scene::model::object::PropSection { title: "Geometry".into(), diff --git a/src/ui/window/gradient_editor.rs b/src/ui/window/gradient_editor.rs index 058afc52e..30d6925bc 100644 --- a/src/ui/window/gradient_editor.rs +++ b/src/ui/window/gradient_editor.rs @@ -1,5 +1,8 @@ +//! Gradient flyout of the Graphic Attributes palette. + use crate::app::Message; use crate::scene::model::hatch_model::GradientKind; +use crate::ui::window::graphic_attributes::GraphicAttributesMsg; use acadrust::types::Color as AcadColor; use iced::widget::{ button, checkbox, column, container, image, mouse_area, row, slider, text, text_input, tooltip, @@ -24,7 +27,26 @@ pub struct GradientEditorState { pub inverted: bool, pub angle: String, pub centered: bool, - pub color_open: Option, +} + +#[derive(Debug, Clone)] +pub enum GradientMsg { + Open, + Cancel, + Apply, + ColorMode(GradientColorMode), + /// Stop colour 1 or 2. + Color(u8, AcadColor), + ShadeTint(f32), + Kind(GradientKind), + InvertToggled, + Angle(String), + AngleReset, + Centered(bool), +} + +fn msg(message: GradientMsg) -> Message { + Message::GraphicAttributes(GraphicAttributesMsg::Gradient(message)) } impl GradientEditorState { @@ -37,7 +59,7 @@ impl GradientEditorState { .gradient_color .colors .get(index) - .map(|entry| entry.color.clone()) + .map(|entry| entry.color) .unwrap_or(AcadColor::Index(7)) }; let (kind, inverted) = GradientKind::from_name(&hatch.gradient_color.name); @@ -55,7 +77,30 @@ impl GradientEditorState { inverted, angle: format!("{:.1}°", hatch.gradient_color.angle.to_degrees()), centered: hatch.gradient_color.shift.abs() < 1e-9, - color_open: None, + } + } + + pub fn update(&mut self, message: GradientMsg) { + match message { + GradientMsg::ColorMode(mode) => self.color_mode = mode, + GradientMsg::Color(1, color) => self.color_1 = color, + GradientMsg::Color(_, color) => self.color_2 = color, + GradientMsg::ShadeTint(value) => self.shade_tint = value.clamp(0.0, 1.0), + GradientMsg::Kind(kind) => { + self.kind = kind; + if kind == GradientKind::Linear { + self.inverted = false; + } + } + GradientMsg::InvertToggled => { + if self.kind != GradientKind::Linear { + self.inverted ^= true; + } + } + GradientMsg::Angle(value) => self.angle = value, + GradientMsg::AngleReset => self.angle = "0.0°".into(), + GradientMsg::Centered(value) => self.centered = value, + GradientMsg::Open | GradientMsg::Cancel | GradientMsg::Apply => {} } } @@ -96,7 +141,7 @@ fn preview_sized( state: &GradientEditorState, width: u32, height: u32, -) -> iced::widget::image::Handle { +) -> Option { let c1 = rgb(&state.color_1); let c2 = if state.color_mode == GradientColorMode::One { let c = crate::scene::gradient_tint_color(rgb_array(&state.color_1), state.shade_tint); @@ -110,8 +155,7 @@ fn preview_sized( angle, if state.centered { 0.0 } else { 1.0 }, cadkernel::geom2d::Tolerance::default(), - ) - .expect("the preview rectangle always defines a gradient frame"); + )?; let (sin, cos) = angle.sin_cos(); let radial = state.kind.radial(); let mut pixels = Vec::with_capacity((width * height * 4) as usize); @@ -143,17 +187,30 @@ fn preview_sized( }); } } - iced::widget::image::Handle::from_rgba(width, height, pixels) + Some(iced::widget::image::Handle::from_rgba(width, height, pixels)) } -fn preview(state: &GradientEditorState) -> iced::widget::image::Handle { - preview_sized(state, 370, 80) +fn preview(state: &GradientEditorState) -> Element<'static, Message> { + preview_image(preview_sized(state, 370, 80)) } -pub(crate) fn compact_preview( - hatch: &acadrust::entities::Hatch, -) -> iced::widget::image::Handle { - preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26) +pub(crate) fn compact_preview(hatch: &acadrust::entities::Hatch) -> Element<'static, Message> { + preview_image(preview_sized( + &GradientEditorState::from_hatch(Vec::new(), hatch), + 256, + 26, + )) +} + +fn preview_image(handle: Option) -> Element<'static, Message> { + match handle { + Some(handle) => image(handle) + .width(Fill) + .height(Fill) + .content_fit(iced::ContentFit::Fill) + .into(), + None => Space::new().width(Fill).height(Fill).into(), + } } fn icon(kind: GradientKind, inverted: bool) -> &'static [u8] { @@ -192,7 +249,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let segment = |label, mode| { let selected = state.color_mode == mode; button(text(label).size(11)) - .on_press(Message::GradientColorModeChanged(mode)) + .on_press(msg(GradientMsg::ColorMode(mode))) .width(Fill) .style(move |theme: &Theme, status| { if selected { @@ -203,8 +260,8 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { }) }; let color_field = |label: &'static str, index, color: AcadColor| { - let (background, _) = crate::ui::properties::acad_color_display(color.clone()); - let value = crate::ui::color_select::color_display_name(color.clone()); + let (background, _) = crate::ui::properties::acad_color_display(color); + let value = crate::ui::color_select::color_display_name(color); let picker = button( row![ crate::ui::color_select::swatch(background), @@ -265,7 +322,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { ] .align_x(iced::Center), ) - .on_press(Message::GradientTypeChanged(kind)) + .on_press(msg(GradientMsg::Kind(kind))) .width(48) .height(48) .style(move |theme: &Theme, status| { @@ -285,7 +342,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let invert_selected = state.inverted && state.kind != GradientKind::Linear; let invert = button(column![text("⇄").size(18), text("Invert").size(9)].align_x(iced::Center)) .on_press_maybe( - (state.kind != GradientKind::Linear).then_some(Message::GradientInvertToggled), + (state.kind != GradientKind::Linear).then(|| msg(GradientMsg::InvertToggled)), ) .width(42) .height(48) @@ -309,18 +366,18 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { )); let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two { row![ - color_field("Color 1", 1, state.color_1.clone()), - color_field("Color 2", 2, state.color_2.clone()) + color_field("Color 1", 1, state.color_1), + color_field("Color 2", 2, state.color_2) ] .spacing(6) .into() } else { row![ - color_field("Color 1", 1, state.color_1.clone()), + color_field("Color 1", 1, state.color_1), row![ text("Tone").size(11).width(43), slider(0..=100, (state.shade_tint * 100.0) as i32, |v| { - Message::GradientShadeTintChanged(v as f32 / 100.0) + msg(GradientMsg::ShadeTint(v as f32 / 100.0)) }) .width(Fill) ] @@ -336,12 +393,12 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let angle = row![ text("Angle").size(11).width(43), text_input("0.0°", &state.angle) - .on_input(Message::GradientAngleChanged) + .on_input(|value| msg(GradientMsg::Angle(value))) .size(11) .width(Length::Fixed(92.0)), tooltip( button(crate::ui::icons::themed_undo(11.0, true)) - .on_press(Message::GradientAngleReset) + .on_press(msg(GradientMsg::AngleReset)) .style(button::subtle) .padding(3) .width(24) @@ -352,7 +409,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { Space::new().width(Fill), checkbox(state.centered) .label("Centered") - .on_toggle(Message::GradientCenteredChanged) + .on_toggle(|value| msg(GradientMsg::Centered(value))) .size(14) .text_size(11) ] @@ -364,11 +421,11 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { let actions = container( row![ button(text(crate::t!("Cancel")).size(11)) - .on_press(Message::GradientCancel) + .on_press(msg(GradientMsg::Cancel)) .style(button::secondary) .padding([5, 14]), button(text(crate::t!("OK")).size(11)) - .on_press(Message::GradientApply) + .on_press(msg(GradientMsg::Apply)) .style(button::primary) .padding([5, 18]), ] @@ -377,7 +434,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { .width(Fill) .align_x(iced::alignment::Horizontal::Right); let content = column![ - image(preview(state)).width(Fill).height(80), + container(preview(state)).width(Fill).height(80), row![ segment("One Color", GradientColorMode::One), segment("Two Colors", GradientColorMode::Two) diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 1a44ca701..e1a50bc55 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -1,73 +1,37 @@ -//! Graphic Attributes palette: apply or remove an associative object fill. +//! Graphic Attributes palette: line attributes and the associative fill of +//! the selected objects. -use crate::app::Message; +use crate::app::config::DockSide; +use crate::app::{ColorPickTarget, Message}; +use crate::scene::Scene; use crate::ui::dock::{DockMsg, PanelId}; +use crate::ui::window::gradient_editor::{GradientEditorState, GradientMsg}; +use acadrust::entities::{EntityCommon, Hatch}; +use acadrust::types::{Color as AcadColor, LineWeight, Transparency}; use acadrust::{CadDocument, EntityType, Handle}; use iced::widget::{ - button, column, combo_box, container, image, mouse_area, row, slider, text, text_input, - tooltip, Space, + button, column, combo_box, container, mouse_area, row, slider, text, text_input, tooltip, + Space, }; use iced::{Background, Border, Element, Fill, Length, Padding, Theme}; +use rustc_hash::{FxHashMap, FxHashSet}; -pub(crate) struct SolidFillColorInfo { - pub color: acadrust::types::Color, - pub display: iced::Color, - pub varies: bool, -} - -struct TransparencyInfo { - value: u8, - varies: bool, - mode: PropertyMode, -} - -#[derive(Clone, Copy)] -enum PropertyMode { - ByLayer, - ByBlock, - Custom, - Varies, -} - -impl PropertyMode { - fn button_label(self) -> &'static str { - match self { - Self::ByLayer => "L", - Self::ByBlock => "B", - Self::Custom => "C", - Self::Varies => "V", - } - } -} - -struct LinetypeInfo { - value: String, - varies: bool, -} - -struct LineweightInfo { - value: acadrust::types::LineWeight, - varies: bool, -} - -struct LinetypeScaleInfo { - value: f64, - varies: bool, -} +type DrawDepth = FxHashMap; const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0; const COMPACT_BUTTON_HEIGHT: f32 = 22.0; -const LINETYPE_SCALE_MIN: f64 = 0.1; -const LINETYPE_SCALE_MAX: f64 = 1000.0; const LINETYPE_SCALE_STEP: f64 = 0.1; const TRANSPARENCY_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/transparency.svg"); const LINEWEIGHT_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/lineweight.svg"); +/// A usable object linetype scale (CELTSCALE): any positive finite value. +/// Spinner steps are rounded to six decimals so repeated `±0.1` steps do not +/// accumulate floating-point noise; typed values such as the ISO pen widths +/// 0.13, 0.18, 0.25 and 0.35 are kept as entered. pub(crate) fn normalized_linetype_scale(value: f64) -> Option { - value - .is_finite() - .then(|| ((value * 10.0).round() / 10.0).clamp(LINETYPE_SCALE_MIN, LINETYPE_SCALE_MAX)) + let value = (value * 1.0e6).round() / 1.0e6; + (value.is_finite() && value > 0.0).then_some(value) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -82,6 +46,16 @@ pub enum GraphicAttribute { impl GraphicAttribute { pub const ALL: [Self; 4] = [Self::None, Self::Solid, Self::Hatch, Self::Gradient]; + fn of(hatch: &Hatch) -> Self { + if hatch.gradient_color.enabled { + Self::Gradient + } else if hatch.is_solid { + Self::Solid + } else { + Self::Hatch + } + } + fn icon(self) -> &'static [u8] { match self { Self::Varies | Self::None => { @@ -106,21 +80,127 @@ impl std::fmt::Display for GraphicAttribute { } } +/// The palette's drop-down menus; at most one is open at a time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Menu { + Header, + Fill, + LineColor, + LineLinetype, + LineLineweight, + LineTransparency, + FillColor, + FillTransparency, +} + +#[derive(Debug, Clone)] +pub enum GraphicAttributesMsg { + ToggleMenu(Menu), + CloseMenu, + SetAllByLayer, + SetAllByBlock, + RemoveReferences, + CreateLayer, + Fill(GraphicAttribute), + LineColor(AcadColor), + LineLinetype(String), + LineLineweight(LineWeight), + LineLinetypeScale(f64), + LineTransparency(Transparency), + FillColor(AcadColor), + FillTransparency(Transparency), + HatchEditorOpen, + HatchEditorClose, + HatchPatternSearch(String), + HatchPatternFocus(usize), + HatchPattern(String), + HatchPatternConfirm, + Gradient(GradientMsg), +} + +fn msg(message: GraphicAttributesMsg) -> Message { + Message::GraphicAttributes(message) +} + +/// The hatch pattern flyout, opened for one selection. +#[derive(Debug)] +pub struct HatchEditorState { + pub handles: Vec, + pub search: String, + pub focus: usize, +} + +/// A fill request waiting for the user to decide whether its open +/// boundaries are closed first. +#[derive(Debug)] +pub struct PendingFillClose { + pub fill: GraphicAttribute, + pub open_boundaries: Vec, + pub selection: Vec, +} + +#[derive(Debug, Default)] +pub struct GraphicAttributesState { + pub open_menu: Option, + pub hatch_editor: Option, + pub gradient_editor: Option, + pub pending_fill_close: Option, +} + +impl GraphicAttributesState { + pub fn close_popups(&mut self) { + self.open_menu = None; + self.hatch_editor = None; + self.gradient_editor = None; + } + + /// Whether a flyout or question is bound to the selection it was opened for. + pub fn tracks_selection(&self) -> bool { + self.hatch_editor.is_some() + || self.gradient_editor.is_some() + || self.pending_fill_close.is_some() + } + + /// Drop flyouts and questions that were opened for another selection. + pub fn retain_selection(&mut self, selected: &[Handle]) { + let same = |handles: &[Handle]| { + handles.len() == selected.len() && selected.iter().all(|h| handles.contains(h)) + }; + if self.gradient_editor.as_ref().is_some_and(|e| !same(&e.handles)) { + self.gradient_editor = None; + } + if self.hatch_editor.as_ref().is_some_and(|e| !same(&e.handles)) { + self.hatch_editor = None; + } + if self.pending_fill_close.as_ref().is_some_and(|p| !same(&p.selection)) { + self.pending_fill_close = None; + } + } + + pub fn fill_close_prompt(&self) -> Option { + let count = self.pending_fill_close.as_ref()?.open_boundaries.len(); + Some(if count == 1 { + "The selected object is open. Close it before creating the fill? [Yes / No] " + .to_string() + } else { + format!( + "{count} selected objects are open. Close them before creating the fill? [Yes / No] " + ) + }) + } +} + +/// The fill kind of each selected object: its own kind for a selected HATCH, +/// otherwise the kind of the associative HATCH it bounds. pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { // Scan the document once, rather than once per selected object on every frame. - let mut fills: rustc_hash::FxHashMap> = + let mut fills: FxHashMap> = selected.iter().map(|handle| (*handle, None)).collect(); for entity in document.entities() { let EntityType::Hatch(hatch) = entity else { continue; }; - let value = if hatch.gradient_color.enabled { - GraphicAttribute::Gradient - } else if hatch.is_solid { - GraphicAttribute::Solid - } else { - GraphicAttribute::Hatch - }; + let value = GraphicAttribute::of(hatch); if let Some(fill) = fills.get_mut(&entity.common().handle) { fill.get_or_insert(value); } @@ -147,8 +227,9 @@ pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAtt } } +/// HATCH entities that are selected or associated with a selected boundary. pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec { - let selected: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); + let selected: FxHashSet<_> = selected.iter().copied().collect(); document .entities() .filter_map(|entity| { @@ -157,19 +238,21 @@ pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec Vec { let mut handles = Vec::new(); - let mut seen = rustc_hash::FxHashSet::default(); + let mut seen = FxHashSet::default(); for handle in selected { match document.get_entity(*handle) { Some(EntityType::Hatch(hatch)) if hatch.is_associative => { @@ -192,187 +275,173 @@ pub(crate) fn line_handles(document: &CadDocument, selected: &[Handle]) -> Vec, -) -> Option { +/// The handle drawn on top, which decides the value shown for a selection. +pub(crate) fn top_handle(handles: &[Handle], draw_depth: &DrawDepth) -> Option { handles.iter().copied().max_by(|left, right| { - let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth.get(&right.value()).map_or(0.0, |depth| depth[0]); - left.total_cmp(&right) + let depth = |handle: &Handle| draw_depth.get(&handle.value()).map_or(0.0, |d| d[0]); + depth(left).total_cmp(&depth(right)) }) } -fn transparency_info( - document: &CadDocument, +/// The value of the topmost object and whether any of `handles` differs. +fn shared_value<'a, T: PartialEq>( + document: &'a CadDocument, handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let top_entity = document.get_entity(top)?; - let stored = top_entity.common().transparency; - let effective = if stored.is_by_layer() { - document - .layers - .get(&top_entity.common().layer) - .map_or(stored, |layer| layer.transparency) - } else if stored.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - stored - }; - Some(TransparencyInfo { - value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, - varies: handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| entity.common().transparency != stored) - }), - mode: if stored.is_by_layer() { - PropertyMode::ByLayer - } else if stored.is_by_block() { - PropertyMode::ByBlock - } else { - PropertyMode::Custom - }, - }) -} - -fn color_info( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let top_entity = document.get_entity(top)?; - let color = top_entity.common().color; + draw_depth: &DrawDepth, + value: impl Fn(&EntityCommon) -> T, +) -> Option<(&'a EntityCommon, T, bool)> { + let top = document.get_entity(top_handle(handles, draw_depth)?)?.common(); + let shared = value(top); let varies = handles.iter().any(|handle| { document .get_entity(*handle) - .is_some_and(|entity| entity.common().color != color) + .is_some_and(|entity| value(entity.common()) != shared) }); - let rgba = crate::scene::view::render::render_style_for_common_viewport( - document, - top_entity.common(), - None, - ) - .0; - Some(SolidFillColorInfo { + Some((top, shared, varies)) +} + +fn current_layer_name(document: &CadDocument) -> &str { + if document.header.current_layer_name.is_empty() { + "0" + } else { + &document.header.current_layer_name + } +} + +/// Layer of the topmost object, or the current layer without a selection. +fn top_layer<'a>(document: &'a CadDocument, handles: &[Handle], draw_depth: &DrawDepth) -> &'a str { + top_handle(handles, draw_depth) + .and_then(|handle| document.get_entity(handle)) + .map_or_else( + || current_layer_name(document), + |entity| entity.common().layer.as_str(), + ) +} + +#[derive(Clone, Copy)] +enum PropertyMode { + ByLayer, + ByBlock, + Custom, + Varies, +} + +impl PropertyMode { + fn button_label(self) -> &'static str { + match self { + Self::ByLayer => "L", + Self::ByBlock => "B", + Self::Custom => "C", + Self::Varies => "V", + } + } +} + +struct ColorInfo { + color: AcadColor, + display: iced::Color, + varies: bool, +} + +impl ColorInfo { + fn mode(&self) -> PropertyMode { + match (self.varies, self.color) { + (true, _) => PropertyMode::Varies, + (false, AcadColor::ByLayer) => PropertyMode::ByLayer, + (false, AcadColor::ByBlock) => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + } +} + +struct TransparencyInfo { + value: u8, + stored: Transparency, + varies: bool, +} + +impl TransparencyInfo { + fn new(document: &CadDocument, layer: &str, stored: Transparency, varies: bool) -> Self { + let effective = if stored.is_by_layer() { + document + .layers + .get(layer) + .map_or(stored, |layer| layer.transparency) + } else if stored.is_by_block() { + Transparency::from_percent(0.0) + } else { + stored + }; + Self { + value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, + stored, + varies, + } + } + + fn mode(&self) -> PropertyMode { + if self.varies { + PropertyMode::Varies + } else if self.stored.is_by_layer() { + PropertyMode::ByLayer + } else if self.stored.is_by_block() { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + } + } +} + +fn color_info(document: &CadDocument, handles: &[Handle], draw_depth: &DrawDepth) -> Option { + let (top, color, varies) = shared_value(document, handles, draw_depth, |c| c.color)?; + let rgba = + crate::scene::view::render::render_style_for_common_viewport(document, top, None).0; + Some(ColorInfo { color, display: iced::Color::from_rgba(rgba[0], rgba[1], rgba[2], 1.0), varies, }) } -fn linetype_info( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let value = document.get_entity(top)?.common().linetype.clone(); - let varies = handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| entity.common().linetype != value) - }); - Some(LinetypeInfo { - value: if value.is_empty() { - "ByLayer".to_string() - } else { - value - }, - varies, - }) +/// Line colour for new objects (CECOLOR) when nothing is selected. +fn current_color_info(document: &CadDocument) -> ColorInfo { + let color = document.header.current_entity_color; + let shown = match color { + AcadColor::ByLayer => document + .layers + .get(current_layer_name(document)) + .map_or(color, |layer| layer.color), + _ => color, + }; + ColorInfo { + color, + display: crate::ui::properties::acad_color_display(shown).0, + varies: false, + } } -fn lineweight_info( +fn transparency_info( document: &CadDocument, handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let value = document.get_entity(top)?.common().line_weight; - let varies = handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| entity.common().line_weight != value) - }); - Some(LineweightInfo { value, varies }) + draw_depth: &DrawDepth, +) -> Option { + let (top, stored, varies) = shared_value(document, handles, draw_depth, |c| c.transparency)?; + Some(TransparencyInfo::new(document, &top.layer, stored, varies)) } fn effective_lineweight( document: &CadDocument, handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - stored: acadrust::types::LineWeight, -) -> acadrust::types::LineWeight { - if !matches!(stored, acadrust::types::LineWeight::ByLayer) { + draw_depth: &DrawDepth, + stored: LineWeight, +) -> LineWeight { + if !matches!(stored, LineWeight::ByLayer) { return stored; } - let layer_name = top_handle(handles, draw_depth) - .and_then(|handle| document.get_entity(handle)) - .map(|entity| entity.common().layer.as_str()) - .unwrap_or_else(|| { - if document.header.current_layer_name.is_empty() { - "0" - } else { - document.header.current_layer_name.as_str() - } - }); document .layers - .get(layer_name) - .map_or(acadrust::types::LineWeight::Default, |layer| { - layer.line_weight - }) -} - -fn linetype_scale_info( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> Option { - let top = top_handle(handles, draw_depth)?; - let value = document.get_entity(top)?.common().linetype_scale; - let varies = handles.iter().any(|handle| { - document - .get_entity(*handle) - .is_some_and(|entity| (entity.common().linetype_scale - value).abs() > f64::EPSILON) - }); - Some(LinetypeScaleInfo { value, varies }) -} - -fn effective_linetype_is_continuous( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, -) -> bool { - let (name, layer) = top_handle(handles, draw_depth) - .and_then(|handle| document.get_entity(handle)) - .map(|entity| { - ( - entity.common().linetype.as_str(), - entity.common().layer.as_str(), - ) - }) - .unwrap_or_else(|| { - ( - current_linetype_name(document), - document.header.current_layer_name.as_str(), - ) - }); - let name = if name.is_empty() || name.eq_ignore_ascii_case("ByLayer") { - document - .layers - .get(if layer.is_empty() { "0" } else { layer }) - .map_or("Continuous", |layer| layer.line_type.as_str()) - } else if name.eq_ignore_ascii_case("ByBlock") { - "Continuous" - } else { - name - }; - name.eq_ignore_ascii_case("Continuous") || name.eq_ignore_ascii_case("Solid") + .get(top_layer(document, handles, draw_depth)) + .map_or(LineWeight::Default, |layer| layer.line_weight) } fn current_linetype_name(document: &CadDocument) -> &str { @@ -389,42 +458,33 @@ fn current_linetype_name(document: &CadDocument) -> &str { } } -fn linetype_preview_art( - document: &CadDocument, - handles: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - logical_name: &str, -) -> String { - let layer_name = top_handle(handles, draw_depth) - .and_then(|handle| document.get_entity(handle)) - .map(|entity| entity.common().layer.as_str()) - .unwrap_or_else(|| { - if document.header.current_layer_name.is_empty() { - "0" - } else { - document.header.current_layer_name.as_str() - } - }); - let effective_name = if logical_name.is_empty() - || logical_name.eq_ignore_ascii_case("ByLayer") - || logical_name.eq_ignore_ascii_case("ByBlock") - { +/// The linetype actually drawn: ByLayer resolves to the layer's linetype, +/// ByBlock to Continuous. +fn effective_linetype<'a>(document: &'a CadDocument, layer: &str, logical: &'a str) -> &'a str { + if logical.is_empty() || logical.eq_ignore_ascii_case("ByLayer") { document .layers - .get(layer_name) + .get(if layer.is_empty() { "0" } else { layer }) .map_or("Continuous", |layer| layer.line_type.as_str()) + } else if logical.eq_ignore_ascii_case("ByBlock") { + "Continuous" } else { - logical_name - }; - if effective_name.eq_ignore_ascii_case("Continuous") - || effective_name.eq_ignore_ascii_case("Solid") - { + logical + } +} + +fn is_continuous(name: &str) -> bool { + name.eq_ignore_ascii_case("Continuous") || name.eq_ignore_ascii_case("Solid") +} + +fn linetype_preview_art(document: &CadDocument, linetype: &str) -> String { + if is_continuous(linetype) { return "_".repeat(80); } let art = document .line_types .iter() - .find(|line_type| line_type.name.eq_ignore_ascii_case(effective_name)) + .find(|line_type| line_type.name.eq_ignore_ascii_case(linetype)) .map(|line_type| { if line_type.elements.is_empty() { "____________".to_string() @@ -454,50 +514,31 @@ fn linetype_preview_art( full_width } +/// The topmost selected fill of the given kind. fn top_fill_hatch<'a>( document: &'a CadDocument, - selected: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - expected: GraphicAttribute, -) -> Option<&'a acadrust::entities::Hatch> { - let selected: rustc_hash::FxHashSet<_> = selected.iter().copied().collect(); - document - .entities() - .filter_map(|entity| { - let EntityType::Hatch(hatch) = entity else { - return None; - }; - let value = if hatch.gradient_color.enabled { - GraphicAttribute::Gradient - } else if hatch.is_solid { - GraphicAttribute::Solid - } else { - GraphicAttribute::Hatch - }; - if value != expected { - return None; - } - let directly_selected = selected.contains(&entity.common().handle); - let selected_boundary = hatch.is_associative - && hatch.paths.iter().any(|path| { - path.boundary_handles - .iter() - .any(|handle| selected.contains(handle)) - }); - (directly_selected || selected_boundary).then_some((entity.common().handle, hatch)) + fills: &[Handle], + draw_depth: &DrawDepth, + kind: GraphicAttribute, +) -> Option<&'a Hatch> { + let fills: Vec<_> = fills + .iter() + .copied() + .filter(|handle| { + matches!(document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) if GraphicAttribute::of(hatch) == kind) }) - .max_by(|(left, _), (right, _)| { - let left = draw_depth.get(&left.value()).map_or(0.0, |depth| depth[0]); - let right = draw_depth.get(&right.value()).map_or(0.0, |depth| depth[0]); - left.total_cmp(&right) - }) - .map(|(_, hatch)| hatch) + .collect(); + match document.get_entity(top_handle(&fills, draw_depth)?)? { + EntityType::Hatch(hatch) => Some(hatch), + _ => None, + } } -fn gradients_vary(document: &CadDocument, selected: &[Handle]) -> bool { - let mut gradients = fill_handles(document, selected) - .into_iter() - .filter_map(|handle| match document.get_entity(handle) { +fn gradients_vary(document: &CadDocument, fills: &[Handle]) -> bool { + let mut gradients = fills + .iter() + .filter_map(|handle| match document.get_entity(*handle) { Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled => { Some(&hatch.gradient_color) } @@ -509,10 +550,10 @@ fn gradients_vary(document: &CadDocument, selected: &[Handle]) -> bool { gradients.any(|gradient| gradient != first) } -fn popup_alignment(side: crate::app::config::DockSide) -> iced_aw::drop_down::Alignment { +fn popup_alignment(side: DockSide) -> iced_aw::drop_down::Alignment { match side { - crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, - crate::app::config::DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, + DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, } } @@ -525,21 +566,27 @@ fn compact_button_content(label: &'static str) -> Element<'static, Message> { .into() } -fn mode_button_content(mode: PropertyMode) -> Element<'static, Message> { - compact_button_content(mode.button_label()) +fn popup_item<'a>(label: &'a str, message: Message) -> Element<'a, Message> { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + .into() } -fn by_mode_menu<'a>( - side: crate::app::config::DockSide, +/// The small L / B / C / V button next to a control, with its drop-down. +fn mode_menu<'a>( + side: DockSide, mode: PropertyMode, - open: bool, - toggle: Message, - close: Message, - by_layer: Message, - by_block: Message, + menu: Menu, + open_menu: Option, + height: f32, + items: Vec<(&'a str, Message)>, ) -> Element<'a, Message> { - let more = button(mode_button_content(mode)) - .on_press(toggle) + let open = open_menu == Some(menu); + let more = button(compact_button_content(mode.button_label())) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(menu))) .style(move |theme: &Theme, status| { if open { button::primary(theme, status) @@ -547,22 +594,16 @@ fn by_mode_menu<'a>( button::secondary(theme, status) } }) - .height(COMPACT_BUTTON_HEIGHT) + .height(height) .padding([1, 3]); if !open { return more.into(); } - let choice = |label, message| { - button(text(label).size(11)) - .on_press(message) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - choice("ByLayer", by_layer), - choice("ByBlock", by_block), - ]) + let popup = container(column( + items + .into_iter() + .map(|(label, message)| popup_item(label, message)), + )) .style(crate::ui::color_select::popup_panel_style) .padding(2); iced_aw::DropDown::new(more, popup, true) @@ -570,45 +611,43 @@ fn by_mode_menu<'a>( .height(Length::Shrink) .alignment(popup_alignment(side)) .offset(2.0) - .on_dismiss(close) + .on_dismiss(msg(GraphicAttributesMsg::CloseMenu)) .into() } fn color_control<'a>( - info: SolidFillColorInfo, - side: crate::app::config::DockSide, - menu_open: bool, - target: crate::app::ColorPickTarget, - toggle: Message, - close: Message, - changed: fn(acadrust::types::Color) -> Message, + info: ColorInfo, + side: DockSide, + menu: Menu, + open_menu: Option, + target: ColorPickTarget, + changed: fn(AcadColor) -> GraphicAttributesMsg, ) -> Element<'a, Message> { let label = if info.varies { Some("*VARIES*") + } else if info.color == AcadColor::ByBlock { + Some("ByBlock") } else { - match info.color { - acadrust::types::Color::ByLayer => None, - acadrust::types::Color::ByBlock => Some("ByBlock"), - _ => None, - } - } - .map(|label| { - container(text(label).size(10)) + None + }; + let label: Element<'a, Message> = match label { + Some(label) => container(text(label).size(10)) .width(Fill) .align_x(iced::Left) .align_y(iced::Center) .padding([0, 6]) - }); - let color = button(label.unwrap_or_else(|| container(Space::new()).width(Fill).height(Fill))) + .into(), + None => container(Space::new()).width(Fill).height(Fill).into(), + }; + let display = info.display; + let swatch = button(label) .on_press(Message::OpenColorWindow(target.clone(), info.color)) .width(Fill) .height(COMPACT_BUTTON_HEIGHT) .padding(0) .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(info.display)), - text_color: if info.display.r * 0.299 + info.display.g * 0.587 + info.display.b * 0.114 - > 0.55 - { + background: Some(Background::Color(display)), + text_color: if display.r * 0.299 + display.g * 0.587 + display.b * 0.114 > 0.55 { iced::Color::BLACK } else { iced::Color::WHITE @@ -624,134 +663,66 @@ fn color_control<'a>( }, ..Default::default() }); - let mode = if info.varies { - PropertyMode::Varies - } else { - match info.color { - acadrust::types::Color::ByLayer => PropertyMode::ByLayer, - acadrust::types::Color::ByBlock => PropertyMode::ByBlock, - _ => PropertyMode::Custom, - } - }; - let more = button(mode_button_content(mode)) - .on_press(toggle) - .style(move |theme: &Theme, status| { - if menu_open { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(COMPACT_BUTTON_HEIGHT) - .padding([1, 3]); - let more: Element<'a, Message> = if menu_open { - let choice = |label, color| { - button(text(label).size(11)) - .on_press(changed(color)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - choice("ByLayer", acadrust::types::Color::ByLayer), - choice("ByBlock", acadrust::types::Color::ByBlock), - button(text("Custom...").size(11)) - .on_press(Message::OpenColorWindow(target, info.color)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - iced_aw::DropDown::new(more, popup, true) - .width(Length::Fixed(110.0)) - .height(Length::Shrink) - .alignment(popup_alignment(side)) - .offset(2.0) - .on_dismiss(close) - .into() - } else { - more.into() - }; - row![color, more].spacing(4).align_y(iced::Center).into() + let more = mode_menu( + side, + info.mode(), + menu, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer", msg(changed(AcadColor::ByLayer))), + ("ByBlock", msg(changed(AcadColor::ByBlock))), + ("Custom...", Message::OpenColorWindow(target, info.color)), + ], + ); + row![swatch, more].spacing(4).align_y(iced::Center).into() } fn transparency_control<'a>( info: TransparencyInfo, - side: crate::app::config::DockSide, - menu_open: bool, - toggle: Message, - close: Message, - changed: fn(u8) -> Message, - by_layer: Message, - by_block: Message, + side: DockSide, + menu: Menu, + open_menu: Option, + changed: fn(Transparency) -> GraphicAttributesMsg, ) -> Element<'a, Message> { let value_label = if info.varies { "*VARIES*".to_string() } else { format!("{}%", info.value) }; + let percent = |value: u8| Transparency::from_percent(f64::from(value) / 100.0); let control = row![ crate::ui::icons::semantic(TRANSPARENCY_ICON, PALETTE_CONTROL_ICON_SIZE), text(value_label).size(10).width(38), - slider(0..=90, i32::from(info.value), move |value| changed( - value as u8 - )) + slider(0..=90, i32::from(info.value), move |value| { + msg(changed(percent(value as u8))) + }) .width(Fill), ] .spacing(5) .align_y(iced::Center) .height(crate::ui::ROW_H); - let mode = if info.varies { - PropertyMode::Varies - } else { - info.mode - }; - let more = button(mode_button_content(mode)) - .on_press(toggle) - .style(move |theme: &Theme, status| { - if menu_open { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(crate::ui::ROW_H) - .padding([1, 3]); - let more: Element<'a, Message> = if menu_open { - let row_button = |label, message| { - button(text(label).size(11)) - .on_press(message) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]) - }; - let popup = container(column![ - row_button("ByLayer", by_layer), - row_button("ByBlock", by_block), - row_button("Custom", changed(info.value)), - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - iced_aw::DropDown::new(more, popup, true) - .width(Length::Fixed(110.0)) - .height(Length::Shrink) - .alignment(popup_alignment(side)) - .offset(2.0) - .on_dismiss(close) - .into() - } else { - more.into() - }; + let more = mode_menu( + side, + info.mode(), + menu, + open_menu, + crate::ui::ROW_H, + vec![ + ("ByLayer", msg(changed(Transparency::BY_LAYER))), + ("ByBlock", msg(changed(Transparency::BY_BLOCK))), + ("Custom", msg(changed(percent(info.value)))), + ], + ); row![control, more].spacing(4).align_y(iced::Center).into() } -fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'static, Message> { +fn header_menu(side: DockSide, open: bool) -> Element<'static, Message> { let menu_button = button(crate::ui::icons::themed_secondary( crate::ui::icons::MENU, 12.0, )) - .on_press(Message::ToggleGraphicAttributesHeaderMenu) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(Menu::Header))) .style(move |theme: &Theme, status| { if open { button::primary(theme, status) @@ -766,28 +737,16 @@ fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'stati let item = |label: &'static str, message| { button(text(label).size(11)) - .on_press(message) + .on_press(msg(message)) .style(crate::ui::color_select::list_row_style) .width(Fill) .padding([4, 8]) }; let popup = container(column![ - item( - "Set all attributes ByLayer", - Message::GraphicAttributesSetAllByLayer, - ), - item( - "Set all attributes ByBlock", - Message::GraphicAttributesSetAllByBlock, - ), - item( - "Remove ByLayer / ByBlock", - Message::GraphicAttributesRemoveReferences, - ), - item( - "Create Layer with active settings", - Message::GraphicAttributesCreateLayer, - ), + item("Set all attributes ByLayer", GraphicAttributesMsg::SetAllByLayer), + item("Set all attributes ByBlock", GraphicAttributesMsg::SetAllByBlock), + item("Remove ByLayer / ByBlock", GraphicAttributesMsg::RemoveReferences), + item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer), ]) .style(crate::ui::color_select::popup_panel_style) .padding(2); @@ -796,33 +755,11 @@ fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'stati .height(Length::Shrink) .alignment(popup_alignment(side)) .offset(2.0) - .on_dismiss(Message::CloseGraphicAttributesHeaderMenu) + .on_dismiss(msg(GraphicAttributesMsg::CloseMenu)) .into() } -pub fn view<'a>( - document: &'a CadDocument, - properties: &'a crate::ui::properties::PropertiesPanel, - selected: &[Handle], - draw_depth: &rustc_hash::FxHashMap, - width: f32, - auto_collapse: bool, - side: crate::app::config::DockSide, - header_menu_open: bool, - menu_open: bool, - line_color_menu_open: bool, - line_linetype_menu_open: bool, - line_lineweight_menu_open: bool, - line_transparency_menu_open: bool, - solid_color_menu_open: bool, - transparency_menu_open: bool, - hatch_editor_open: bool, - hatch_pattern_search: &'a str, - hatch_pattern_focus: usize, - gradient_editor: Option<&'a crate::ui::window::gradient_editor::GradientEditorState>, - gradient_color_picker_open: bool, -) -> Element<'a, Message> { - let menu = header_menu(side, header_menu_open); +fn header<'a>(side: DockSide, auto_collapse: bool, menu_open: bool) -> Element<'a, Message> { let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { @@ -834,12 +771,7 @@ pub fn view<'a>( ))) .style(button::subtle) .padding([3, 5]); - let pin = tooltip( - pin, - text(crate::t!("Auto")).size(10), - tooltip::Position::Bottom, - ) - .gap(4); + let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); let close = button(crate::ui::icons::themed_secondary( crate::ui::icons::CLOSE, 12.0, @@ -847,18 +779,13 @@ pub fn view<'a>( .on_press(Message::Dock(DockMsg::Close(PanelId::GraphicAttributes))) .style(button::subtle) .padding([3, 5]); - let close = tooltip( - close, - text(crate::t!("Close")).size(10), - tooltip::Position::Bottom, - ) - .gap(4); - let header = mouse_area( + let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); + mouse_area( container( row![ text(crate::t!("Graphic Attributes")).size(12), Space::new().width(Fill), - menu, + header_menu(side, menu_open), pin, close ] @@ -873,27 +800,29 @@ pub fn view<'a>( .padding([3, 6]), ) .on_press(Message::Dock(DockMsg::DockGrab(PanelId::GraphicAttributes))) - .interaction(iced::mouse::Interaction::Grab); + .interaction(iced::mouse::Interaction::Grab) + .into() +} - let current = current(document, selected); +fn fill_picker<'a>(current: GraphicAttribute, open: bool) -> Element<'a, Message> { let picker = button( row![ crate::ui::icons::semantic(current.icon(), 14.0), text(current.to_string()).size(11), Space::new().width(Fill), - crate::ui::icons::themed_arrow_toggle(menu_open, 9.0), + crate::ui::icons::themed_arrow_toggle(open, 9.0), ] .spacing(5) .align_y(iced::Center), ) - .on_press(Message::ToggleGraphicAttributeDropdown) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(Menu::Fill))) .style(move |theme: &Theme, _| { let palette = theme.palette(); button::Style { background: Some(Background::Color(palette.background.base.color)), text_color: palette.background.base.text, border: Border { - color: if menu_open { + color: if open { palette.primary.base.color } else { palette.background.neutral.color @@ -907,515 +836,509 @@ pub fn view<'a>( .height(crate::ui::ROW_H) .width(Fill) .padding([3, 6]); - let picker: Element<'_, Message> = if menu_open { - let mut rows = column![].spacing(0); - for value in GraphicAttribute::ALL { - rows = rows.push( - button( - row![ - crate::ui::icons::semantic(value.icon(), 14.0), - text(value.to_string()).size(11), - ] - .spacing(5) - .align_y(iced::Center), - ) - .on_press(Message::GraphicAttributeChanged(value)) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([2, 4]), - ); - } - let popup = container(rows) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - crate::ui::color_select::drop_down_below( - picker.into(), - popup.into(), - None, - Length::Shrink, - Message::CloseGraphicAttributeDropdown, + if !open { + return picker.into(); + } + let rows = column(GraphicAttribute::ALL.into_iter().map(|value| { + button( + row![ + crate::ui::icons::semantic(value.icon(), 14.0), + text(value.to_string()).size(11), + ] + .spacing(5) + .align_y(iced::Center), ) - } else { - picker.into() - }; - let line_handles = line_handles(document, selected); - let fill_handles = fill_handles(document, selected); - let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); - let no_selection = selected.is_empty(); - let line_color = color_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| { - let color = document.header.current_entity_color; - let display = if color == acadrust::types::Color::ByLayer { - document - .layers - .get(if document.header.current_layer_name.is_empty() { - "0" + .on_press(msg(GraphicAttributesMsg::Fill(value))) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + .into() + })); + let popup = container(rows) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + crate::ui::color_select::drop_down_below( + picker.into(), + popup.into(), + None, + Length::Shrink, + msg(GraphicAttributesMsg::CloseMenu), + ) +} + +/// A preview button plus the `⋮` button that toggles its editor flyout. +fn preview_row<'a>( + preview: Element<'a, Message>, + editor_open: bool, + toggle: GraphicAttributesMsg, + tooltip_label: &'static str, + flyout: Option<(Element<'a, Message>, f32, Option)>, + side: DockSide, +) -> Element<'a, Message> { + let preview = button(preview) + .on_press(msg(toggle.clone())) + .width(Fill) + .height(COMPACT_BUTTON_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| button::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: if editor_open + || matches!(status, button::Status::Hovered | button::Status::Pressed) + { + theme.palette().primary.base.color } else { - &document.header.current_layer_name - }) - .map_or_else( - || crate::ui::properties::acad_color_display(color).0, - |layer| crate::ui::properties::acad_color_display(layer.color).0, - ) - } else { - crate::ui::properties::acad_color_display(color).0 - }; - SolidFillColorInfo { - color, - display, - varies: false, - } - }) - }); - let linetype = linetype_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| LinetypeInfo { - value: current_linetype_name(document).to_string(), - varies: false, - }) - }); - let continuous_linetype = linetype.as_ref().is_some_and(|info| { - !info.varies && effective_linetype_is_continuous(document, &line_handles, draw_depth) - }); - let linetype_scale = linetype_scale_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| LinetypeScaleInfo { - value: document.header.current_entity_linetype_scale, - varies: false, - }) - }); - if let Some(info) = linetype.as_ref() { - let selected = (!info.varies).then(|| crate::ui::properties::LinetypeItem { - // An empty name makes only the preview art visible in the closed - // Graphic Attributes control. Menu items retain their full names. - name: String::new(), - art: linetype_preview_art(document, &line_handles, draw_depth, &info.value), - }); - let combo = combo_box( - &properties.linetype_combo, - "*VARIES*", - selected.as_ref(), - |item: crate::ui::properties::LinetypeItem| Message::LineLinetypeChanged(item.name), - ) - .size(crate::ui::ROW_H * 0.42) - .padding(Padding { - top: 3.0, - bottom: 3.0, - left: 6.0, - right: 6.0, - }) - .input_style(crate::ui::properties::combo_input_style) - .width(Fill); - let more = by_mode_menu( - side, - if info.varies { - PropertyMode::Varies - } else if info.value.eq_ignore_ascii_case("ByLayer") { - PropertyMode::ByLayer - } else if info.value.eq_ignore_ascii_case("ByBlock") { - PropertyMode::ByBlock - } else { - PropertyMode::Custom + theme.palette().background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), }, - line_linetype_menu_open, - Message::ToggleLineLinetypeMenu, - Message::CloseLineLinetypeMenu, - Message::LineLinetypeChanged("ByLayer".to_string()), - Message::LineLinetypeChanged("ByBlock".to_string()), - ); - body = body.push( - row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] - .spacing(4) - .align_y(iced::Center), - ); - } - let lineweight = lineweight_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| LineweightInfo { - value: acadrust::types::LineWeight::from_value(document.header.current_line_weight), - varies: false, - }) - }); - if let Some(color) = line_color { - body = body.push(color_control( - color, - side, - line_color_menu_open, - crate::app::ColorPickTarget::GraphicAttributesLine, - Message::ToggleLineColorDropdown, - Message::CloseLineColorDropdown, - Message::LineColorChanged, - )); - } - if let (Some(scale), Some(lineweight)) = (linetype_scale, lineweight) { - let scale_field: Element<'_, Message> = if scale.varies { - text_input("*VARIES*", "") - .size(crate::ui::ROW_H * 0.42) - .padding([5, 5]) - .style(crate::ui::properties::combo_input_style) - .width(Fill) - .into() - } else { - let scale_value = normalized_linetype_scale(scale.value).unwrap_or(1.0); - let mut input = iced_aw::number_input( - &scale_value, - LINETYPE_SCALE_MIN..=LINETYPE_SCALE_MAX, - |value: f64| { - Message::LineLinetypeScaleChanged( - normalized_linetype_scale(value).unwrap_or(1.0), - ) - }, - ) - .step(LINETYPE_SCALE_STEP) - .set_size(crate::ui::ROW_H * 0.42) - .padding([5, 5]) - .input_style(move |theme: &Theme, status| { - let mut style = crate::ui::properties::combo_input_style(theme, status); - if continuous_linetype { - let disabled = theme.palette().background.base.text.scale_alpha(0.42); - style.value = disabled; - style.placeholder = disabled; - } - style - }) - .style(move |theme: &Theme, status| { - let text = theme.palette().background.base.text; - iced_aw::number_input::number_input::Style { - button_background: None, - icon_color: if continuous_linetype - || status == iced_aw::style::Status::Disabled - { - text.scale_alpha(0.42) - } else { - text - }, - } - }) - .width(Fill); - if continuous_linetype { - input = input.on_input_maybe(None:: Message>); + ..Default::default() + }); + let edit = button(compact_button_content("⋮")) + .on_press(msg(toggle)) + .style(move |theme: &Theme, status| { + if editor_open { + button::primary(theme, status) } else { - input = input.on_submit(Message::Noop); + button::secondary(theme, status) } - input.into() - }; - let scale_icon: Element<'_, Message> = if continuous_linetype { - crate::ui::icons::themed_disabled( - GraphicAttribute::Solid.icon(), - PALETTE_CONTROL_ICON_SIZE, - ) - } else { - crate::ui::icons::semantic( - GraphicAttribute::Solid.icon(), - PALETTE_CONTROL_ICON_SIZE, - ) - }; - let scale = row![scale_icon, scale_field] - .spacing(3) - .align_y(iced::Center) - .width(Length::FillPortion(1)); - let selected = (!lineweight.varies).then_some(crate::ui::properties::LwItem( - effective_lineweight(document, &line_handles, draw_depth, lineweight.value), - )); - let lineweight_mode = if lineweight.varies { - PropertyMode::Varies - } else { - match lineweight.value { - acadrust::types::LineWeight::ByLayer => PropertyMode::ByLayer, - acadrust::types::LineWeight::ByBlock => PropertyMode::ByBlock, - _ => PropertyMode::Custom, - } - }; - let lineweight_picker = combo_box( - &properties.lineweight_combo, - "*VARIES*", - selected.as_ref(), - |item: crate::ui::properties::LwItem| Message::LineLineweightChanged(item.0), - ) - .size(crate::ui::ROW_H * 0.42) - .padding(Padding { - top: 3.0, - bottom: 3.0, - left: 6.0, - right: 6.0, }) - .input_style(crate::ui::properties::combo_input_style) - .width(Fill); - let more = by_mode_menu( - side, - lineweight_mode, - line_lineweight_menu_open, - Message::ToggleLineLineweightMenu, - Message::CloseLineLineweightMenu, - Message::LineLineweightChanged(acadrust::types::LineWeight::ByLayer), - Message::LineLineweightChanged(acadrust::types::LineWeight::ByBlock), - ); - let lineweight = row![ - crate::ui::icons::semantic(LINEWEIGHT_ICON, PALETTE_CONTROL_ICON_SIZE), - lineweight_picker, - more, - ] - .spacing(3) - .align_y(iced::Center) - .width(Length::FillPortion(1)); - body = body.push( - row![scale, lineweight] - .spacing(12) - .align_y(iced::Center) - .height(crate::ui::ROW_H), - ); - } - let line_transparency = transparency_info(document, &line_handles, draw_depth).or_else(|| { - no_selection.then(|| { - let stored = document.current_entity_transparency(); - let effective = if stored.is_by_layer() { - document - .layers - .get(if document.header.current_layer_name.is_empty() { - "0" - } else { - &document.header.current_layer_name - }) - .map_or(stored, |layer| layer.transparency) - } else if stored.is_by_block() { - acadrust::types::Transparency::from_percent(0.0) - } else { - stored - }; - TransparencyInfo { - value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, - varies: false, - mode: if stored.is_by_layer() { - PropertyMode::ByLayer - } else if stored.is_by_block() { - PropertyMode::ByBlock - } else { - PropertyMode::Custom - }, + .height(COMPACT_BUTTON_HEIGHT) + .padding([1, 3]); + let edit = tooltip(edit, text(tooltip_label).size(10), tooltip::Position::Bottom); + let edit: Element<'a, Message> = match flyout { + Some((popup, width, height)) => { + let mut drop_down = iced_aw::DropDown::new(edit, popup, true) + .width(Length::Fixed(width)) + .alignment(popup_alignment(side)) + .offset(4.0); + if let Some(height) = height { + drop_down = drop_down + .height(Length::Fixed(height)) + .on_dismiss(msg(GraphicAttributesMsg::HatchEditorClose)); + } + drop_down.into() } - }) + None => edit.into(), + }; + row![preview, edit].spacing(4).align_y(iced::Center).into() +} + +fn linetype_row<'a>( + document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, + linetype: &str, + varies: bool, + layer: &str, + side: DockSide, + open_menu: Option, +) -> Element<'a, Message> { + let selected = (!varies).then(|| crate::ui::properties::LinetypeItem { + // An empty name shows only the preview art in the closed control; + // the menu items keep their full names. + name: String::new(), + art: linetype_preview_art(document, effective_linetype(document, layer, linetype)), }); - if let Some(info) = line_transparency { - body = body.push(transparency_control( - info, - side, - line_transparency_menu_open, - Message::ToggleLineTransparencyDropdown, - Message::CloseLineTransparencyDropdown, - Message::LineTransparencyChanged, - Message::LineTransparencyByLayer, - Message::LineTransparencyByBlock, - )); + let combo = combo_box( + &properties.linetype_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LinetypeItem| { + msg(GraphicAttributesMsg::LineLinetype(item.name)) + }, + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let mode = if varies { + PropertyMode::Varies + } else if linetype.eq_ignore_ascii_case("ByLayer") { + PropertyMode::ByLayer + } else if linetype.eq_ignore_ascii_case("ByBlock") { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + }; + let more = mode_menu( + side, + mode, + Menu::LineLinetype, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer", msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))), + ("ByBlock", msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))), + ], + ); + row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] + .spacing(4) + .align_y(iced::Center) + .into() +} + +fn linetype_scale_field<'a>(scale: f64, varies: bool, continuous: bool) -> Element<'a, Message> { + let field: Element<'a, Message> = if varies { + text_input("*VARIES*", "") + .size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .style(crate::ui::properties::combo_input_style) + .width(Fill) + .into() + } else { + let value = normalized_linetype_scale(scale).unwrap_or(1.0); + let mut input = iced_aw::number_input( + &value, + (std::ops::Bound::Excluded(0.0), std::ops::Bound::Unbounded), + |value: f64| match normalized_linetype_scale(value) { + Some(value) => msg(GraphicAttributesMsg::LineLinetypeScale(value)), + None => Message::Noop, + }, + ) + .step(LINETYPE_SCALE_STEP) + .set_size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .input_style(move |theme: &Theme, status| { + let mut style = crate::ui::properties::combo_input_style(theme, status); + if continuous { + let disabled = theme.palette().background.base.text.scale_alpha(0.42); + style.value = disabled; + style.placeholder = disabled; + } + style + }) + .style(move |theme: &Theme, status| { + let text = theme.palette().background.base.text; + iced_aw::number_input::number_input::Style { + button_background: None, + icon_color: if continuous || status == iced_aw::style::Status::Disabled { + text.scale_alpha(0.42) + } else { + text + }, + } + }) + .width(Fill); + if continuous { + input = input.on_input_maybe(None:: Message>); + } else { + input = input.on_submit(Message::Noop); + } + input.into() + }; + let icon = GraphicAttribute::Solid.icon(); + let icon = if continuous { + crate::ui::icons::themed_disabled(icon, PALETTE_CONTROL_ICON_SIZE) + } else { + crate::ui::icons::semantic(icon, PALETTE_CONTROL_ICON_SIZE) + }; + row![icon, field] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)) + .into() +} + +fn lineweight_field<'a>( + properties: &'a crate::ui::properties::PropertiesPanel, + stored: LineWeight, + effective: LineWeight, + varies: bool, + side: DockSide, + open_menu: Option, +) -> Element<'a, Message> { + let selected = (!varies).then_some(crate::ui::properties::LwItem(effective)); + let mode = if varies { + PropertyMode::Varies + } else { + match stored { + LineWeight::ByLayer => PropertyMode::ByLayer, + LineWeight::ByBlock => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + }; + let picker = combo_box( + &properties.lineweight_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LwItem| msg(GraphicAttributesMsg::LineLineweight(item.0)), + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let more = mode_menu( + side, + mode, + Menu::LineLineweight, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))), + ("ByBlock", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))), + ], + ); + row![ + crate::ui::icons::semantic(LINEWEIGHT_ICON, PALETTE_CONTROL_ICON_SIZE), + picker, + more, + ] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)) + .into() +} + +/// The Line section: values of the selection's line objects, or the +/// creation defaults when nothing is selected. +fn line_section<'a>( + document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, + handles: &[Handle], + no_selection: bool, + draw_depth: &DrawDepth, + side: DockSide, + open_menu: Option, +) -> Vec> { + let mut rows = Vec::new(); + if handles.is_empty() && !no_selection { + return rows; } + let layer = top_layer(document, handles, draw_depth).to_string(); + let header = &document.header; + let (linetype, linetype_varies) = shared_value(document, handles, draw_depth, |c| { + c.linetype.clone() + }) + .map_or_else( + || (current_linetype_name(document).to_string(), false), + |(_, value, varies)| (if value.is_empty() { "ByLayer".into() } else { value }, varies), + ); + let (scale, scale_varies) = + shared_value(document, handles, draw_depth, |c| c.linetype_scale).map_or( + (header.current_entity_linetype_scale, false), + |(_, value, varies)| (value, varies), + ); + let (lineweight, lineweight_varies) = + shared_value(document, handles, draw_depth, |c| c.line_weight).map_or( + (LineWeight::from_value(header.current_line_weight), false), + |(_, value, varies)| (value, varies), + ); + let color = color_info(document, handles, draw_depth) + .unwrap_or_else(|| current_color_info(document)); + let transparency = transparency_info(document, handles, draw_depth).unwrap_or_else(|| { + TransparencyInfo::new(document, &layer, document.current_entity_transparency(), false) + }); + let continuous = + !linetype_varies && is_continuous(effective_linetype(document, &layer, &linetype)); + + rows.push(linetype_row( + document, + properties, + &linetype, + linetype_varies, + &layer, + side, + open_menu, + )); + rows.push(color_control( + color, + side, + Menu::LineColor, + open_menu, + ColorPickTarget::GraphicAttributesLine, + GraphicAttributesMsg::LineColor, + )); + let effective = effective_lineweight(document, handles, draw_depth, lineweight); + rows.push( + row![ + linetype_scale_field(scale, scale_varies, continuous), + lineweight_field( + properties, + lineweight, + effective, + lineweight_varies, + side, + open_menu + ), + ] + .spacing(12) + .align_y(iced::Center) + .height(crate::ui::ROW_H) + .into(), + ); + rows.push(transparency_control( + transparency, + side, + Menu::LineTransparency, + open_menu, + GraphicAttributesMsg::LineTransparency, + )); + rows +} + +pub fn view<'a>( + state: &'a GraphicAttributesState, + scene: &'a Scene, + properties: &'a crate::ui::properties::PropertiesPanel, + width: f32, + auto_collapse: bool, + side: DockSide, + gradient_color_picking: bool, +) -> Element<'a, Message> { + let document = &scene.document; + let selected = scene.selected_handles_in_order(); + let draw_depth = scene.draw_depth_map(); + let draw_depth = draw_depth.as_ref(); + let open_menu = state.open_menu; + let line_handles = line_handles(document, &selected); + let fill_handles = fill_handles(document, &selected); + + let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); + body = body.extend(line_section( + document, + properties, + &line_handles, + selected.is_empty(), + draw_depth, + side, + open_menu, + )); + + let current = current(document, &selected); body = body .push(Space::new().height(2)) .push(text(crate::t!("Fill")).size(11)) - .push(picker); + .push(fill_picker(current, open_menu == Some(Menu::Fill))); if !matches!(current, GraphicAttribute::None | GraphicAttribute::Gradient) { if let Some(info) = color_info(document, &fill_handles, draw_depth) { body = body.push(color_control( info, side, - solid_color_menu_open, - crate::app::ColorPickTarget::GraphicAttributesSolid, - Message::ToggleSolidFillColorDropdown, - Message::CloseSolidFillColorDropdown, - Message::SolidFillColorChanged, + Menu::FillColor, + open_menu, + ColorPickTarget::GraphicAttributesSolid, + GraphicAttributesMsg::FillColor, )); } } - if current == GraphicAttribute::Gradient { - if let Some(hatch) = - top_fill_hatch(document, selected, draw_depth, GraphicAttribute::Gradient) - { - let toggle = if gradient_editor.is_some() { - Message::GradientCancel - } else { - Message::GradientEditorOpen - }; - let preview_image = image(crate::ui::window::gradient_editor::compact_preview(hatch)) - .width(Fill) - .height(Fill) - .content_fit(iced::ContentFit::Fill); - let preview_content: Element<'_, Message> = if gradients_vary(document, selected) { - iced::widget::stack![ - preview_image, - container(text("*VARIES*").size(10)) - .width(Fill) - .height(Fill) - .align_x(iced::Left) - .align_y(iced::Center) - .padding([0, 6]), - ] - .into() - } else { - preview_image.into() - }; - let preview = button(preview_content) - .on_press(toggle.clone()) - .width(Fill) - .height(crate::ui::ROW_H) - .padding(0) - .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: if gradient_editor.is_some() - || matches!(status, button::Status::Hovered | button::Status::Pressed) - { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + match current { + GraphicAttribute::Hatch => { + let hatch = top_fill_hatch(document, &fill_handles, draw_depth, current); + let preview = hatch + .and_then(|hatch| Scene::hatch_model_from_dxf(hatch, [1.0; 4])) + .map(|model| crate::ui::properties::compact_hatch_pattern_preview(model.pattern)) + .unwrap_or_else(|| Space::new().width(Fill).height(Fill).into()); + let flyout = state.hatch_editor.as_ref().map(|editor| { + let picker = crate::ui::properties::hatch_pattern_picker_content( + &editor.search, + editor.focus, + hatch.map_or("", |hatch| hatch.pattern.name.as_str()), + "graphic-hatch-pattern-search", + |search| msg(GraphicAttributesMsg::HatchPatternSearch(search)), + msg(GraphicAttributesMsg::HatchPatternConfirm), + |index| msg(GraphicAttributesMsg::HatchPatternFocus(index)), + |name| msg(GraphicAttributesMsg::HatchPattern(name)), + ); + let popup = container(picker) + .padding(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + (popup.into(), 352.0, Some(724.0)) }); - let edit = button(compact_button_content("⋮")) - .on_press(toggle) - .style(move |theme: &Theme, status| { - if gradient_editor.is_some() { - button::primary(theme, status) - } else { - button::secondary(theme, status) - } - }) - .height(COMPACT_BUTTON_HEIGHT) - .padding([1, 3]); - let edit = tooltip( - edit, - text("Edit gradient").size(10), - tooltip::Position::Bottom, - ); - let edit: Element<'_, Message> = if let Some(editor) = gradient_editor { - let alignment = match side { - crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, - crate::app::config::DockSide::Right => { - iced_aw::drop_down::Alignment::BottomStart - } - }; - iced_aw::DropDown::new( - edit, - crate::ui::window::gradient_editor::view(editor, gradient_color_picker_open), - true, - ) - .width(Length::Fixed(390.0)) - .alignment(alignment) - .offset(4.0) - .into() - } else { - edit.into() - }; - body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); - } - } else if current == GraphicAttribute::Hatch { - let hatch = top_fill_hatch(document, selected, draw_depth, GraphicAttribute::Hatch); - let current_pattern = hatch.map_or("", |hatch| hatch.pattern.name.as_str()); - let preview_content: Element<'_, Message> = hatch - .and_then(|hatch| { - crate::scene::Scene::hatch_model_from_dxf(hatch, [1.0; 4]) - .map(|model| model.pattern) - }) - .map(crate::ui::properties::compact_hatch_pattern_preview) - .unwrap_or_else(|| { - container(Space::new()) - .width(Fill) - .height(crate::ui::ROW_H) - .into() - }); - let preview = button(preview_content) - .on_press(if hatch_editor_open { - Message::HatchEditorClose - } else { - Message::HatchEditorOpen - }) - .width(Fill) - .height(COMPACT_BUTTON_HEIGHT) - .padding(0) - .style(move |theme: &Theme, status| button::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: if hatch_editor_open - || matches!(status, button::Status::Hovered | button::Status::Pressed) - { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }); - let edit = button(compact_button_content("⋮")) - .on_press(if hatch_editor_open { - Message::HatchEditorClose - } else { - Message::HatchEditorOpen - }) - .style(move |theme: &Theme, status| { - if hatch_editor_open { - button::primary(theme, status) + let open = state.hatch_editor.is_some(); + body = body.push(preview_row( + preview, + open, + if open { + GraphicAttributesMsg::HatchEditorClose } else { - button::secondary(theme, status) - } - }) - .height(crate::ui::ROW_H) - .padding([1, 3]); - let edit = tooltip(edit, text("Edit hatch").size(10), tooltip::Position::Bottom); - let edit: Element<'_, Message> = if hatch_editor_open { - let popup = crate::ui::properties::hatch_pattern_picker_content( - hatch_pattern_search, - hatch_pattern_focus, - current_pattern, - "graphic-hatch-pattern-search", - Message::GraphicHatchPatternSearchChanged, - Message::GraphicHatchPatternConfirm, - Message::GraphicHatchPatternFocus, - Message::GraphicHatchPatternChanged, - ); - let popup = container(popup) - .padding(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().primary.base.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() + GraphicAttributesMsg::HatchEditorOpen + }, + "Edit hatch", + flyout, + side, + )); + } + GraphicAttribute::Gradient => { + if let Some(hatch) = top_fill_hatch(document, &fill_handles, draw_depth, current) { + let preview = crate::ui::window::gradient_editor::compact_preview(hatch); + let preview = if gradients_vary(document, &fill_handles) { + iced::widget::stack![ + preview, + container(text("*VARIES*").size(10)) + .width(Fill) + .height(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]), + ] + .into() + } else { + preview + }; + let flyout = state.gradient_editor.as_ref().map(|editor| { + ( + crate::ui::window::gradient_editor::view(editor, gradient_color_picking), + 390.0, + None, + ) }); - iced_aw::DropDown::new(edit, popup, true) - .width(Length::Fixed(352.0)) - .height(Length::Fixed(724.0)) - .alignment(popup_alignment(side)) - .offset(4.0) - .on_dismiss(Message::HatchEditorClose) - .into() - } else { - edit.into() - }; - body = body.push(row![preview, edit].spacing(4).align_y(iced::Center)); + let open = state.gradient_editor.is_some(); + body = body.push(preview_row( + preview, + open, + GraphicAttributesMsg::Gradient(if open { + GradientMsg::Cancel + } else { + GradientMsg::Open + }), + "Edit gradient", + flyout, + side, + )); + } + } + GraphicAttribute::None | GraphicAttribute::Solid | GraphicAttribute::Varies => {} } if current != GraphicAttribute::None { if let Some(info) = transparency_info(document, &fill_handles, draw_depth) { body = body.push(transparency_control( info, side, - transparency_menu_open, - Message::ToggleFillTransparencyDropdown, - Message::CloseFillTransparencyDropdown, - Message::FillTransparencyChanged, - Message::FillTransparencyByLayer, - Message::FillTransparencyByBlock, + Menu::FillTransparency, + open_menu, + GraphicAttributesMsg::FillTransparency, )); } } - let body = body.padding(8); - container(column![header, body]) - .width(Length::Fixed(width)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - ..Default::default() - }) - .into() + container(column![ + header(side, auto_collapse, open_menu == Some(Menu::Header)), + body.padding(8) + ]) + .width(Length::Fixed(width)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .into() } diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 0944cb779..3b5421bd7 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,32 +1,32 @@ pub mod about; -pub mod alias_editor; -pub mod annotation_data; -pub mod attribute_editor; -pub mod auto_constrain_settings; pub mod block_definition; pub mod block_palette; pub mod browser; -pub mod drafting_settings; +pub mod layout_manager; +pub mod layer_state_manager; pub mod drawing_units; -pub mod find_replace; pub mod geometric_tolerance; pub mod gradient_editor; pub mod graphic_attributes; -pub mod layer_state_manager; +pub mod drafting_settings; +pub mod auto_constrain_settings; pub mod layer_translator; -pub mod layers; -pub mod layout_manager; -pub mod missing_fonts; -pub mod named_parameters; -pub mod open_progress; -pub mod options; pub mod plot; -pub mod plugin_manager; pub mod print_all; -pub mod recovery; +pub mod plugin_manager; pub mod shortcuts; +pub mod layers; pub mod update_notice; -pub mod wblock; +pub mod open_progress; +pub mod missing_fonts; +pub mod recovery; +pub mod options; +pub mod attribute_editor; +pub mod annotation_data; +pub mod alias_editor; +pub mod find_replace; +pub mod named_parameters; pub mod xref_attach; pub mod xref_help; pub mod xref_manager; +pub mod wblock;