diff --git a/assets/icons/blocks/preview_size.svg b/assets/icons/blocks/preview_size.svg new file mode 100644 index 000000000..537a29d9c --- /dev/null +++ b/assets/icons/blocks/preview_size.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bffdc70b5..dadf2bdb6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3141,34 +3141,38 @@ impl OpenCADStudio { /// this drawing doesn't already have. Each recreated record gets a fresh /// handle from the target document so it can't collide with an existing /// one. No-op for same-document pastes (the records already exist). (#129) - pub(super) fn merge_clipboard_deps(&mut self, i: usize) { + pub(super) fn merge_dependencies( + &mut self, + i: usize, + deps: &crate::app::ClipboardDeps, + ) { use acadrust::TableEntry; - if self.clipboard_deps.is_empty() { + if deps.is_empty() { return; } let doc = &mut self.tabs[i].scene.document; - for rec in &self.clipboard_deps.layers { + for rec in &deps.layers { if !doc.layers.contains(rec.name()) { let mut r = rec.clone(); r.set_handle(doc.allocate_handle()); let _ = doc.layers.add(r); } } - for rec in &self.clipboard_deps.linetypes { + for rec in &deps.linetypes { if !doc.line_types.contains(rec.name()) { let mut r = rec.clone(); r.set_handle(doc.allocate_handle()); let _ = doc.line_types.add(r); } } - for rec in &self.clipboard_deps.text_styles { + for rec in &deps.text_styles { if !doc.text_styles.contains(rec.name()) { let mut r = rec.clone(); r.set_handle(doc.allocate_handle()); let _ = doc.text_styles.add(r); } } - for rec in &self.clipboard_deps.dim_styles { + for rec in &deps.dim_styles { if !doc.dim_styles.contains(rec.name()) { let mut r = rec.clone(); r.set_handle(doc.allocate_handle()); @@ -3177,6 +3181,11 @@ impl OpenCADStudio { } } + pub(super) fn merge_clipboard_deps(&mut self, i: usize) { + let deps = self.clipboard_deps.clone(); + self.merge_dependencies(i, &deps); + } + /// Recreate any block definition the pasted INSERTs reference but tab /// `i`'s document lacks (cross-drawing paste), so the block reference /// renders its geometry instead of nothing. No-op for same-document diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index ccc6a1d64..65499e4a0 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -456,19 +456,14 @@ impl OpenCADStudio { ).as_ref()); } - // BLOCKPALETTE — list the blocks available to insert. + // BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel. "BLOCKPALETTE" | "BLOCKSPALETTE" => { - let blocks = self.tabs[i].scene.custom_block_names(); - if blocks.is_empty() { - self.command_line.push_info( - "No user-defined blocks. Define one with BLOCK, then INSERT it.", - ); - } else { - self.command_line.push_output(crate::tf!( - "Blocks ({}) — insert with INSERT : {}", - blocks.len(), - blocks.join(", ") - ).as_ref()); + self.show_block_palette ^= true; + if self.show_block_palette { + // Always open expanded so the panel is immediately usable; + // the user can still collapse it via the title-bar button. + self.block_palette_expanded = true; + self.refresh_block_palette(); } } @@ -705,4 +700,106 @@ mod tests { "ATTMAN must not run the old command-line listing, got: {attman:?}" ); } + + #[test] + fn blockpalette_toggles_panel() { + let mut app = fresh_app(); + assert!(!app.show_block_palette); + let _ = app.run_command_line("BLOCKPALETTE"); + assert!(app.show_block_palette); + assert!( + app.block_palette_expanded, + "palette must open expanded, not as the collapsed bar" + ); + let _ = app.run_command_line("BLOCKSPALETTE"); + assert!(!app.show_block_palette); + } + + #[test] + fn blockpalette_refresh_lists_blocks() { + let mut app = fresh_app(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut line = acadrust::entities::Line::new(); + line.start = acadrust::types::Vector3::ZERO; + line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0); + app.tabs[i] + .scene + .define_block_from_owned_entities( + vec![acadrust::EntityType::Line(line)], + "Widget", + glam::DVec3::ZERO, + ) + .unwrap(); + let _ = app.run_command_line("BLOCKPALETTE"); + assert!(app.show_block_palette); + assert_eq!(app.block_palette.cached_names, vec!["Widget"]); + assert_eq!(app.block_palette.blocks.len(), 1); + } + + #[test] + fn blockpalette_refresh_if_stale() { + let mut app = fresh_app(); + app.automation_op(r#"{"op":"new"}"#); + app.show_block_palette = true; + app.refresh_block_palette(); + assert!(app.block_palette.blocks.is_empty()); + let i = app.active_tab; + let mut line = acadrust::entities::Line::new(); + line.start = acadrust::types::Vector3::ZERO; + line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0); + app.tabs[i] + .scene + .define_block_from_owned_entities( + vec![acadrust::EntityType::Line(line)], + "Widget", + glam::DVec3::ZERO, + ) + .unwrap(); + app.refresh_block_palette_if_stale(); + assert!(app.block_palette.blocks.iter().any(|b| b.name == "Widget")); + } + + #[test] + fn blockpalette_refreshes_when_switching_to_same_named_block_in_another_tab() { + let mut app = fresh_app(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut line = acadrust::entities::Line::new(); + line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0); + app.tabs[i] + .scene + .define_block_from_owned_entities( + vec![acadrust::EntityType::Line(line)], + "Widget", + glam::DVec3::ZERO, + ) + .unwrap(); + app.show_block_palette = true; + app.refresh_block_palette(); + let first_tab_id = app.tabs[i].id; + + app.tabs + .push(crate::app::document::DocumentTab::new_drawing(99)); + let other = app.tabs.len() - 1; + let mut line = acadrust::entities::Line::new(); + line.end = acadrust::types::Vector3::new(50.0, 0.0, 0.0); + app.tabs[other] + .scene + .define_block_from_owned_entities( + vec![acadrust::EntityType::Line(line)], + "Widget", + glam::DVec3::ZERO, + ) + .unwrap(); + app.active_tab = other; + app.refresh_block_palette_if_stale(); + + assert_ne!(app.tabs[other].id, first_tab_id); + assert_eq!(app.block_palette.source_tab_id, Some(app.tabs[other].id)); + assert_eq!( + app.block_palette.source_block_epoch, + app.tabs[other].scene.block_epoch + ); + } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1ce5cf0c6..6077bf895 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -23,7 +23,7 @@ pub(crate) use view::DrawOrderRefCommand; impl OpenCADStudio { /// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the /// active drawing. Used to auto-name a paste-as-block definition. - fn unique_block_name(&self, prefix: &str) -> String { + pub(super) fn unique_block_name(&self, prefix: &str) -> String { let i = self.active_tab; let mut n = 1; loop { diff --git a/src/app/mod.rs b/src/app/mod.rs index f04872fe1..848d665ef 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -541,6 +541,12 @@ pub(super) struct OpenCADStudio { render_mode_preview: Option, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, + /// Docked Insert Block panel visibility. + pub(crate) show_block_palette: bool, + /// Docked Insert Block panel state (search, preview size, cached thumbnails). + pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette, + /// Whether the narrow-window block-palette bar is expanded. + block_palette_expanded: bool, /// Whether the document file tabs are shown at the top (FILETAB). show_file_tabs: bool, /// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB). @@ -2594,6 +2600,8 @@ pub enum Message { PlotDialogOpen, /// An edit inside the Plot / Print dialog. PlotDlg(crate::ui::window::plot::PlotDlgMsg), + /// An edit inside the docked Insert Block panel. + BlockPalette(crate::ui::window::block_palette::BlockPaletteMsg), /// Open the paper-layout batch output dialog. PrintAllOpen, /// Toggle one paper layout in the batch. @@ -3007,6 +3015,9 @@ impl OpenCADStudio { render_mode_menu_open: false, render_mode_preview: None, show_properties: true, + show_block_palette: false, + block_palette: Default::default(), + block_palette_expanded: false, show_file_tabs: true, show_layout_tabs: true, last_point: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 96e16e360..f41f8b36c 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,6 +2,7 @@ #![allow(unused_imports)] use super::util::*; +use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, @@ -332,4 +333,636 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } + /// Build a unique block name from a file stem: the stem itself, then + /// "stem (2)", "stem (3)", … on collisions. + fn block_name_from_file(&self, stem: &str) -> String { + let i = self.active_tab; + let base = stem.trim(); + if base.is_empty() { + return self.unique_block_name("Block"); + } + let doc = &self.tabs[i].scene.document; + if doc.block_records.get(base).is_none() { + return base.to_string(); + } + let mut n = 2; + loop { + let name = format!("{base} ({n})"); + if doc.block_records.get(&name).is_none() { + return name; + } + n += 1; + } + } + + /// Load an external DWG/DXF at `path` and define its model-space contents as + /// one new block in the active drawing. Returns the new block's name, or an + /// error message. Nested block definitions are imported first so nested + /// INSERTs render (AutoCAD's "inserting a drawing imports its block defs"). + fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result { + let doc = crate::io::load_file(&path).map_err(|e| e.to_string())?; + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Block".to_string()); + self.import_document_as_block(doc, stem) + } + + /// Define one block in the active drawing from a loaded `CadDocument`'s + /// model-space entities (base = the file's model-space insertion base). + fn import_document_as_block( + &mut self, + doc: acadrust::CadDocument, + stem: String, + ) -> Result { + let i = self.active_tab; + // Model-space block record handle (Layout object first, name fallback). + let model_br = doc + .objects + .values() + .find_map(|o| { + if let acadrust::objects::ObjectType::Layout(l) = o { + (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record) + } else { + None + } + }) + .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle)) + .unwrap_or(acadrust::Handle::NULL); + let mut entities: Vec = if model_br.is_null() { + Vec::new() + } else { + let br = doc.block_records.iter().find(|br| br.handle == model_br); + let handles = br.map(|b| b.entity_handles.clone()).unwrap_or_default(); + if !handles.is_empty() { + // Authoritative ownership list (DWG and well-formed DXF). + handles + .iter() + .filter_map(|h| doc.get_entity(*h)) + .filter(|e| { + !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) + }) + .cloned() + .collect() + } else { + // Legacy DXF that omits 330 group codes: treat null-owner + // entities as model-space content (mirrors belongs_to_visible_block). + doc.entities() + .filter(|e| { + let o = e.common().owner_handle; + o == model_br || o.is_null() + }) + .filter(|e| { + !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) + }) + .cloned() + .collect() + } + }; + if entities.is_empty() { + return Err("No model-space entities in that file.".to_string()); + } + let base = glam::DVec3::new( + doc.header.model_space_insertion_base.x, + doc.header.model_space_insertion_base.y, + doc.header.model_space_insertion_base.z, + ); + let name = self.block_name_from_file(&stem); + // Capture every table record needed by the top-level entities and their + // nested block definitions. Importing only the definitions leaves + // source-only layers, linetypes, and text/dimension styles dangling. + let deps = crate::app::ClipboardDeps::capture(&doc, &entities); + // Capture the imported file's nested block definitions once. When a + // name collides with a block already in the active drawing, we must + // *preserve both*: keep the destination's block and import the file's + // under a unique name, then re-point every INSERT at the renamed one — + // otherwise a nested reference silently resolves to the destination's + // unrelated definition. (#135-style collision, but for file imports.) + let mut defs = deps.blocks.clone(); + let mut rename_map: std::collections::HashMap = + std::collections::HashMap::new(); + // Reserve every destination block name plus every imported dependency + // name, so importing a source file that itself holds "Door" and + // "Door (2)" cannot generate a colliding second "Door (2)". + let mut reserved: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); + for existing in self.tabs[i].scene.document.block_records.names() { + reserved.insert(existing.to_string()); + } + for def in &defs { + reserved.insert(def.name.clone()); + } + for def in &defs { + // Keep the name only if it is truly unused in the active drawing. + let used_in_dest = self.tabs[i] + .scene + .document + .block_records + .get(&def.name) + .is_some(); + let dest_name = if used_in_dest { + let mut n = 2; + loop { + let candidate = format!("{} ({})", def.name, n); + if reserved.insert(candidate.clone()) { + break candidate; + } + n += 1; + } + } else { + reserved.insert(def.name.clone()); + def.name.clone() + }; + rename_map.insert(def.name.clone(), dest_name); + } + // Rewrite every INSERT in the model-space entities and in every captured + // definition, then rename the definitions and define them. + for entity in entities + .iter_mut() + .chain(defs.iter_mut().flat_map(|def| def.entities.iter_mut())) + { + if let acadrust::EntityType::Insert(ins) = entity { + if let Some(new_name) = rename_map.get(&ins.block_name) { + ins.block_name = new_name.clone(); + } + } + } + for def in &mut defs { + if let Some(new_name) = rename_map.get(&def.name) { + def.name = new_name.clone(); + } + } + // Every mutation below belongs to the one INSERT FILE undo step. + self.push_undo_snapshot(i, "INSERT FILE"); + self.merge_dependencies(i, &deps); + for def in defs { + self.tabs[i] + .scene + .define_block_raw(&def.name, def.base_point, def.entities); + } + self.tabs[i] + .scene + .define_block_from_owned_entities(entities, &name, base)?; + self.tabs[i].scene.populate_meshes_from_document(); + self.tabs[i].dirty = true; + Ok(name) + } + + 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) => { + self.block_palette.search = s; + iced::Task::none() + } + BlockPaletteMsg::CyclePreviewSize => { + self.block_palette.preview_size = + crate::ui::window::block_palette::cycle_preview_size( + self.block_palette.preview_size, + ); + iced::Task::none() + } + BlockPaletteMsg::Refresh => { + self.refresh_block_palette(); + iced::Task::none() + } + BlockPaletteMsg::ToggleBar => { + // Collapse bar (mirrors the Properties panel). + self.block_palette_expanded ^= true; + if self.block_palette_expanded { + self.refresh_block_palette(); + } + iced::Task::none() + } + BlockPaletteMsg::Close => { + self.show_block_palette = false; + iced::Task::none() + } + BlockPaletteMsg::PickFile => iced::Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .set_title("Select Drawing to Insert as Block") + .add_filter("DWG/DXF Files", &["dwg", "dxf", "DWG", "DXF"]) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), + ), + BlockPaletteMsg::FilePicked(Ok(path)) => { + match self.import_file_as_block(path) { + Ok(name) => { + self.command_line + .push_output(&format!("Inserting \"{name}\" from file.")); + self.refresh_block_palette(); + self.start_block_placement(&name); + } + Err(e) if e != "Cancelled" => { + self.command_line.push_error(&format!("INSERT FILE: {e}")); + } + Err(_) => {} + } + iced::Task::none() + } + BlockPaletteMsg::FilePicked(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(&e); + } + iced::Task::none() + } + BlockPaletteMsg::Insert(name) => { + self.start_block_placement(&name); + iced::Task::none() + } + } + } + + /// Start placing `name` through the INSERT command, skipping the name prompt. + fn start_block_placement(&mut self, name: &str) { + let i = self.active_tab; + let wires = self + .block_palette + .blocks + .iter() + .find(|b| b.name == name) + .map(|b| b.wires.clone()) + .unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name)); + use crate::modules::insert::insert_block::InsertBlockCommand; + let cmd = InsertBlockCommand::new_for_block(name.to_string(), wires, glam::Vec3::ZERO); + use crate::command::CadCommand; + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.block_palette.placing = Some(name.to_string()); + } + + /// Rebuild the panel's block list + cached wires from the active drawing. + pub(crate) fn refresh_block_palette(&mut self) { + let i = self.active_tab; + let names = self.tabs[i].scene.custom_block_names(); + self.block_palette.cached_names = names.clone(); + self.block_palette.source_tab_id = Some(self.tabs[i].id); + self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch; + self.block_palette.blocks = names + .into_iter() + .map(|name| { + let wires = self.tabs[i].scene.block_preview_wires(&name); + crate::ui::window::block_palette::BlockEntry { name, wires } + }) + .collect(); + } + + /// Cheap per-update check: rebuild when the active drawing's definitions + /// changed, even when their names happen to stay the same. + pub(crate) fn refresh_block_palette_if_stale(&mut self) { + if !self.show_block_palette { + return; + } + let i = self.active_tab; + // `block_epoch` advances for every block-definition change, so it + // avoids re-scanning every block name on unrelated application updates. + if self.block_palette.source_tab_id != Some(self.tabs[i].id) + || self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch + { + self.refresh_block_palette(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + use acadrust::entities::Line; + use acadrust::types::Vector3; + use acadrust::EntityType; + + fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + /// 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(); + let model_br = doc + .objects + .values() + .find_map(|o| { + if let acadrust::objects::ObjectType::Layout(l) = o { + (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record) + } else { + None + } + }) + .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle)) + .expect("fresh document has a model-space block record"); + let mut line = Line::new(); + line.common.owner_handle = model_br; + line.start = Vector3::new(0.0, 0.0, 0.0); + line.end = Vector3::new(100.0, 50.0, 0.0); + doc.add_entity(EntityType::Line(line)).unwrap(); + doc + } + + #[test] + 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(); + assert_eq!(name, "Fixture"); + assert!(app.tabs[app.active_tab] + .scene + .document + .block_records + .get("Fixture") + .is_some()); + } + + #[test] + fn import_document_as_block_merges_source_only_layer() { + use acadrust::tables::Layer; + + let mut app = fresh(); + let mut doc = foreign_doc(&app); + let mut layer = Layer::new("Imported Fixtures"); + layer.handle = doc.allocate_handle(); + doc.layers.add(layer).unwrap(); + let model = doc + .objects + .values() + .find_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if l.name == "Model" => { + Some(l.block_record) + } + _ => None, + }) + .unwrap(); + let line = doc + .entities_mut() + .find(|entity| entity.common().owner_handle == model) + .unwrap(); + line.common_mut().layer = "Imported Fixtures".to_string(); + + app.import_document_as_block(doc, "Fixture".to_string()) + .unwrap(); + assert!(app.tabs[app.active_tab] + .scene + .document + .layers + .contains("Imported Fixtures")); + } + + /// A foreign document whose model space INSERTs a `Fixture` block, whose + /// definition itself INSERTs a `Door` block — so the imported `Door` is a + /// *nested dependency*, not a top-level entity. The `Door` in this file is + /// unrelated to any `Door` in the destination drawing. + fn nested_foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + use acadrust::Handle; + let mut doc = foreign_doc(app); + + // "Door" block definition: one LINE of geometry. + let door_h = Handle::new(doc.next_handle()); + let mut door_br = BlockRecord::new("Door"); + door_br.handle = door_h; + doc.block_records.add(door_br).unwrap(); + let mut door_line = Line::new(); + door_line.start = Vector3::new(0.0, 0.0, 0.0); + door_line.end = Vector3::new(5.0, 0.0, 0.0); + door_line.common.owner_handle = door_h; + doc.add_entity(EntityType::Line(door_line)).unwrap(); + + // "Fixture" block definition: INSERTs the nested "Door". + let fixture_h = Handle::new(doc.next_handle()); + let mut fixture_br = BlockRecord::new("Fixture"); + fixture_br.handle = fixture_h; + doc.block_records.add(fixture_br).unwrap(); + let mut nested = Insert::new("Door", Vector3::new(2.0, 0.0, 0.0)); + nested.common.owner_handle = fixture_h; + doc.add_entity(EntityType::Insert(nested)).unwrap(); + + // Model space references the fixture so the import captures it. + let model_br = doc + .objects + .values() + .find_map(|o| { + if let acadrust::objects::ObjectType::Layout(l) = o { + (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record) + } else { + None + } + }) + .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle)) + .expect("fresh document has a model-space block record"); + let mut top = Insert::new("Fixture", Vector3::new(0.0, 0.0, 0.0)); + top.common.owner_handle = model_br; + doc.add_entity(EntityType::Insert(top)).unwrap(); + + doc + } + + /// The nested-INSERT's block name inside a defined block record. + 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)? { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }) + } + + /// A foreign document whose model space INSERTs a `Fixture` block whose + /// definition INSERTs `Door (2)`, whose definition in turn INSERTs `Door`. + /// The file therefore carries *both* `Door` and `Door (2)` as nested deps. + fn doubly_nested_foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument { + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + use acadrust::Handle; + let mut doc = foreign_doc(app); + + let door_h = Handle::new(doc.next_handle()); + let mut door_br = BlockRecord::new("Door"); + door_br.handle = door_h; + doc.block_records.add(door_br).unwrap(); + let mut door_line = Line::new(); + door_line.start = Vector3::new(0.0, 0.0, 0.0); + door_line.end = Vector3::new(5.0, 0.0, 0.0); + door_line.common.owner_handle = door_h; + doc.add_entity(EntityType::Line(door_line)).unwrap(); + + let door2_h = Handle::new(doc.next_handle()); + let mut door2_br = BlockRecord::new("Door (2)"); + door2_br.handle = door2_h; + doc.block_records.add(door2_br).unwrap(); + let mut mid = Insert::new("Door", Vector3::ZERO); + mid.common.owner_handle = door2_h; + doc.add_entity(EntityType::Insert(mid)).unwrap(); + + let fixture_h = Handle::new(doc.next_handle()); + let mut fixture_br = BlockRecord::new("Fixture"); + fixture_br.handle = fixture_h; + doc.block_records.add(fixture_br).unwrap(); + let mut nested = Insert::new("Door (2)", Vector3::new(2.0, 0.0, 0.0)); + nested.common.owner_handle = fixture_h; + doc.add_entity(EntityType::Insert(nested)).unwrap(); + + let model_br = doc + .objects + .values() + .find_map(|o| { + if let acadrust::objects::ObjectType::Layout(l) = o { + (l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record) + } else { + None + } + }) + .or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle)) + .expect("fresh document has a model-space block record"); + let mut top = Insert::new("Fixture", Vector3::new(0.0, 0.0, 0.0)); + top.common.owner_handle = model_br; + doc.add_entity(EntityType::Insert(top)).unwrap(); + + doc + } + + #[test] + fn import_reserves_source_names_so_nested_deps_cannot_collide() { + let mut app = fresh(); + let i = app.active_tab; + // Source file itself carries "Door" and "Door (2)"; destination already + // has "Door". The imported "Door" must land on "Door (3)" — NOT steal + // "Door (2)", which the source file reserves for its own definition. + let doc = doubly_nested_foreign_doc(&app); + let mut dest_door = Line::new(); + dest_door.start = Vector3::new(0.0, 0.0, 0.0); + dest_door.end = Vector3::new(3.0, 3.0, 0.0); + app.tabs[i] + .scene + .define_block_from_owned_entities( + vec![EntityType::Line(dest_door)], + "Door", + glam::DVec3::ZERO, + ) + .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). + assert_eq!( + nested_insert_target(doc, "Door (2)"), + Some("Door (3)".to_string()) + ); + // The file's plain "Door" got bumped to "Door (3)" so both stay distinct. + assert!( + doc.block_records.get("Door").is_some(), + "destination Door preserved" + ); + assert!( + doc.block_records.get("Door (2)").is_some(), + "source Door (2) preserved under its own name" + ); + assert!( + doc.block_records.get("Door (3)").is_some(), + "source Door renamed to Door (3), not Door (2)" + ); + assert_eq!( + nested_insert_target(doc, "Fixture"), + Some("Door (2)".to_string()) + ); + } + + #[test] + fn import_nested_block_collision_preserves_both_definitions() { + let mut app = fresh(); + let i = app.active_tab; + // Build the foreign document from the pristine scene first, so its + // nested "Door" is genuinely distinct from the destination's. + let doc = nested_foreign_doc(&app); + // Destination drawing already has its own, unrelated "Door" block. + let mut dest_door = Line::new(); + dest_door.start = Vector3::new(0.0, 0.0, 0.0); + dest_door.end = Vector3::new(3.0, 3.0, 0.0); + app.tabs[i] + .scene + .define_block_from_owned_entities( + vec![EntityType::Line(dest_door)], + "Door", + glam::DVec3::ZERO, + ) + .unwrap(); + + let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); + assert_eq!(name, "Imported"); + + let doc = &app.tabs[i].scene.document; + // Both definitions survive: the destination's original and the imported one. + assert!( + doc.block_records.get("Door").is_some(), + "destination's own Door must be preserved" + ); + assert!( + doc.block_records.get("Door (2)").is_some(), + "imported nested Door must be renamed to Door (2)" + ); + // The imported Fixture's nested INSERT must point at the imported Door (2), + // not silently resolve to the destination's unrelated Door. + assert_eq!( + nested_insert_target(doc, "Fixture"), + Some("Door (2)".to_string()), + "Fixture must reference the renamed imported Door, not the destination's" + ); + assert_eq!( + nested_insert_target(doc, "Door (2)"), + None, + "imported Door (2) has no nested INSERTs" + ); + } + + #[test] + 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(); + 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"); + assert_eq!(cmd.name(), "INSERT"); + assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); + } + + #[test] + fn blockpalette_collapse_and_close_toggle_state() { + let mut app = fresh(); + app.show_block_palette = true; + app.block_palette_expanded = true; + let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar); + assert!(!app.block_palette_expanded, "collapse hides the panel body"); + let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar); + assert!(app.block_palette_expanded, "bar click re-expands the panel"); + let _ = app.on_block_palette(BlockPaletteMsg::Close); + assert!(!app.show_block_palette, "close dismisses the sidebar"); + } + + #[test] + fn block_name_from_file_avoids_collisions() { + let mut app = fresh(); + let i = app.active_tab; + let mut line = Line::new(); + line.start = Vector3::ZERO; + 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) + .unwrap(); + assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); + assert_eq!(app.block_name_from_file("Table"), "Table"); + } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index adfc5dbac..7defec967 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -239,6 +239,9 @@ impl OpenCADStudio { self.command_line.set_step_options(opts); // Persist UI preferences whenever a toggle changes them (issue #68). self.persist_settings_if_changed(); + // The block panel watches the drawing's block list and rebuilds its + // thumbnails whenever the names change (BLOCK define, file open, …). + self.refresh_block_palette_if_stale(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). @@ -261,6 +264,9 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] crate::sys::set_unsaved_changes_warning(self.tabs.iter().any(|tab| tab.dirty)); + if self.tabs[i].active_cmd.is_none() { + self.block_palette.placing = None; + } task } @@ -5965,6 +5971,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::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { if let Some((_, selected)) = self diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d522a6eeb..95c6267ad 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -5,6 +5,7 @@ use super::{ArrowKey, Message, OpenCADStudio}; use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; +use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ @@ -1445,6 +1446,7 @@ impl OpenCADStudio { self.properties_side, Message::TogglePropertiesBar, Message::PropertiesHover(true), + 26.0, ) } else { let panel = tab @@ -1466,6 +1468,33 @@ impl OpenCADStudio { // the input stays close to the cursor. The Start page gives it a real // layout row instead: its panels and action buttons must end above the // command line rather than rendering behind it (#546). + // The block palette docks on the right, mirroring the Properties panel on the + // left. The collapse button reduces it to a vertical bar whose width equals + // the title height, with the title rotated 90° (see `collapse_bar`). + let block_palette_el: Element<'_, Message> = if tab.is_start { + Space::new().into() + } else if self.show_block_palette && !self.clean_screen { + if self.block_palette_expanded { + crate::ui::window::block_palette::view(&self.block_palette) + } else { + // Collapsed bar width matches the title bar height (icon button + // 30px + 5px top/bottom padding) so expand/collapse is seamless. + collapse_bar( + "Block Palette", + crate::app::config::DockSide::Right, + Message::BlockPalette(BlockPaletteMsg::ToggleBar), + Message::Noop, + 40.0, + ) + } + } else { + Space::new().into() + }; + + // Command-line sits as a bottom-centre overlay on top of the + // viewport stack rather than as a separate row in the main + // column — frees up vertical space when no command is active + // and keeps the input close to where the cursor is drawing. // Autocomplete shows only when no command is collecting its // own input (otherwise typed prefixes are coordinates / values). let allow_autocomplete = tab.active_cmd.is_none(); @@ -1531,6 +1560,9 @@ impl OpenCADStudio { } else { workspace }; + let workspace = row![workspace, block_palette_el] + .width(Fill) + .height(Fill); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2416,6 +2448,7 @@ pub(super) fn collapse_bar<'a>( side: crate::app::config::DockSide, on_press: Message, on_enter: Message, + width: f32, ) -> Element<'a, Message> { let label = canvas(VBarLabel { text: name.to_string(), @@ -2426,7 +2459,7 @@ pub(super) fn collapse_bar<'a>( mouse_area( container(label) - .width(iced::Length::Fixed(26.0)) + .width(iced::Length::Fixed(width)) .height(Fill) .style(|theme: &Theme| container::Style { background: Some(Background::Color( diff --git a/src/modules/insert/mview_block.rs b/src/modules/insert/mview_block.rs index 939683fda..fa0138e4b 100644 --- a/src/modules/insert/mview_block.rs +++ b/src/modules/insert/mview_block.rs @@ -1,9 +1,10 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; -pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mview_block.svg")); +pub const ICON: IconKind = + IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")); pub fn tool() -> ToolDef { ToolDef { id: "BLOCKPALETTE", - label: "Multi-View\nBlock", + label: "Block\nPalette", icon: ICON, event: ModuleEvent::Command("BLOCKPALETTE".to_string()), } diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 821e84993..3da1e735e 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -408,4 +408,55 @@ impl Scene { // bump, so the model isn't re-tessellated on every interim update. self.interim_wire = Some(w); } + + /// Tessellate one block definition's entities into block-local wire models + /// (insertion base at origin) for the block-palette thumbnail. Nested INSERTs + /// expand through the block cache. Unknown / empty block → `vec![]`. + pub(crate) fn block_preview_wires(&self, name: &str) -> Vec { + use acadrust::EntityType; + let Some(br) = self.document.block_records.get(name) else { + return vec![]; + }; + let mut out = Vec::new(); + for &eh in &br.entity_handles { + let Some(e) = self.document.get_entity(eh) else { + continue; + }; + if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) { + continue; + } + out.extend(self.tessellate_one(e)); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + use acadrust::EntityType; + + #[test] + fn block_preview_wires_tessellates_block_entities() { + let mut s = Scene::new(); + let mut line = Line::new(); + line.start = Vector3::new(0.0, 0.0, 0.0); + line.end = Vector3::new(10.0, 5.0, 0.0); + s.define_block_from_owned_entities( + vec![EntityType::Line(line)], + "Widget", + glam::DVec3::ZERO, + ) + .unwrap(); + let wires = s.block_preview_wires("Widget"); + assert!(!wires.is_empty(), "a LINE block must tessellate to wires"); + } + + #[test] + fn block_preview_wires_empty_for_unknown_block() { + let s = Scene::new(); + assert!(s.block_preview_wires("Nope").is_empty()); + } } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 4396b0a6f..9370cc16d 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -910,6 +910,7 @@ fn history_highlight_format( #[cfg(test)] mod tests { use super::{ranked_matches, CommandLine}; + use crate::t; use rustc_hash::FxHashMap; fn aliases(pairs: &[(&str, &str)]) -> FxHashMap { diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs new file mode 100644 index 000000000..ccd0a4cff --- /dev/null +++ b/src/ui/window/block_palette.rs @@ -0,0 +1,377 @@ +//! Docked "Insert Block" panel: a searchable grid of block thumbnails that +//! inserts a clicked block, mirrors the existing `InsertBlockCommand` flow. + +use crate::app::Message; +use crate::modules::IconKind; +use crate::scene::model::wire_model::WireModel; +use iced::widget::canvas::{Frame, Path, Program, Stroke}; +use iced::widget::{button, canvas, column, container, row, scrollable, svg, text, text_input}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +const PANEL_W: f32 = 260.0; +const TOOL_H: f32 = 22.0; +const PANEL_BG: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }; +const PANEL_BORDER: Color = Color { r: 0.22, g: 0.22, b: 0.24, a: 1.0 }; +const CARD_BG: Color = Color { r: 0.16, g: 0.16, b: 0.18, a: 1.0 }; +const CARD_HOVER: Color = Color { r: 0.20, g: 0.20, b: 0.22, a: 1.0 }; +const CARD_ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; +const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; +const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; +/// Character budget for a block card's label so all rows stay one line. +/// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping. +const MAX_LABEL_CHARS: usize = 12; +/// Fixed single-line height of a card's label. Clips any text that still +/// won't fit so every cell in a grid row shares the same height. +const LABEL_LINE_H: f32 = 16.0; + +/// Every message the block palette emits. Wrapped in `Message::BlockPalette`. +#[derive(Debug, Clone)] +pub enum BlockPaletteMsg { + Search(String), + CyclePreviewSize, + PickFile, + Insert(String), + Refresh, + ToggleBar, + Close, + FilePicked(Result), +} + +/// Thumbnail box sizes for the three preview-size settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PreviewSize { + #[default] + Small, + Medium, + Large, +} + +impl PreviewSize { + /// Number of cards per grid row. + fn columns(self) -> usize { + match self { + PreviewSize::Small => 3, + PreviewSize::Medium => 2, + PreviewSize::Large => 1, + } + } + /// Height (px) of the thumbnail box inside each card. + fn box_height(self) -> f32 { + match self { + PreviewSize::Small => 56.0, + PreviewSize::Medium => 84.0, + PreviewSize::Large => 120.0, + } + } +} + +/// Advance to the next preview size (Small → Medium → Large → Small). +pub fn cycle_preview_size(s: PreviewSize) -> PreviewSize { + match s { + PreviewSize::Small => PreviewSize::Medium, + PreviewSize::Medium => PreviewSize::Large, + PreviewSize::Large => PreviewSize::Small, + } +} + +/// One block's cached preview wires. +pub struct BlockEntry { + pub name: String, + pub wires: Vec, +} + +/// Panel state held on the app. +#[derive(Default)] +pub struct BlockPalette { + pub search: String, + pub preview_size: PreviewSize, + /// Cached (name, wires) pairs, rebuilt on refresh. + pub blocks: Vec, + /// Block name currently being placed (INSERT active), for the highlight. + pub placing: Option, + /// Last known block-name list, for the cheap stale check. + pub cached_names: Vec, + /// Document tab and block-definition revision that produced `blocks`. + /// Names alone are not enough: separate drawings can share block names, + /// and a block definition can change without being renamed. + pub source_tab_id: Option, + pub source_block_epoch: u64, +} + +/// Canvas program that draws a block's wires fit-to-box inside its bounds. +struct BlockPreviewCanvas<'a> { + wires: &'a [WireModel], +} + +impl<'a> Program for BlockPreviewCanvas<'a> { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + _theme: &Theme, + bounds: iced::Rectangle, + _cursor: iced::mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let pad = 6.0; + let inner_w = (bounds.width - 2.0 * pad).max(1.0); + let inner_h = (bounds.height - 2.0 * pad).max(1.0); + let (mut minx, mut miny, mut maxx, mut maxy) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + let mut any = false; + for w in self.wires { + for p in &w.points { + if p[0].is_finite() && p[1].is_finite() { + minx = minx.min(p[0]); + miny = miny.min(p[1]); + maxx = maxx.max(p[0]); + maxy = maxy.max(p[1]); + any = true; + } + } + } + if !any { + // Placeholder box so an empty block still reads as a card. + let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h)); + frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }); + return vec![frame.into_geometry()]; + } + let span_x = (maxx - minx).max(1e-6); + let span_y = (maxy - miny).max(1e-6); + let scale = (inner_w / span_x).min(inner_h / span_y); + let ox = (bounds.width - span_x * scale) * 0.5; + let oy = (bounds.height - span_y * scale) * 0.5; + // World up → screen down; centre the fitted AABB. + let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); + // Solid fills first so strokes sit on top. + for w in self.wires { + let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 }; + for tri in w.fill_tris.chunks(3) { + if tri.len() == 3 { + let path = Path::new(|p| { + p.move_to(map(tri[0][0], tri[0][1])); + p.line_to(map(tri[1][0], tri[1][1])); + p.line_to(map(tri[2][0], tri[2][1])); + p.close(); + }); + frame.fill(&path, col); + } + } + } + // Polylines, split into runs on NaN separators (NaN = polyline break). + for w in self.wires { + let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 }; + let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 }; + let mut run: Vec = Vec::new(); + let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| { + if run.len() >= 2 { + let path = Path::new(|p| { + p.move_to(run[0]); + for &pt in &run[1..] { + p.line_to(pt); + } + }); + frame.stroke(&path, Stroke::default().with_color(col).with_width(width)); + } + run.clear(); + }; + for p in &w.points { + if p[0].is_finite() && p[1].is_finite() { + run.push(map(p[0], p[1])); + } else { + flush(&mut frame, &mut run, col, width); + } + } + flush(&mut frame, &mut run, col, width); + } + vec![frame.into_geometry()] + } +} + +/// Build the docked panel element from the palette state. +pub fn view(palette: &BlockPalette) -> Element<'_, Message> { + let query = palette.search.trim().to_lowercase(); + let filtered: Vec<&BlockEntry> = palette + .blocks + .iter() + .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query)) + .collect(); + + // ── Title bar (title, collapse, close) ────────────────────────────────── + let title_bar = container( + row![ + text("Block Palette").size(12).color(TEXT), + iced::widget::Space::new().width(Fill), + icon_button( + IconKind::Svg(include_bytes!("../../../assets/icons/ui/tri_right.svg")), + BlockPaletteMsg::ToggleBar, + ), + icon_button( + IconKind::Svg(include_bytes!("../../../assets/icons/ui/close.svg")), + BlockPaletteMsg::Close, + ), + ] + .spacing(4) + .align_y(iced::Center), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(CARD_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .width(Fill) + .padding([5, 6]); + + let search_input = text_input("Search blocks…", &palette.search) + .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v))) + .padding([4, 8]) + .size(12); + + let header = row![ + search_input, + icon_button( + IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), + BlockPaletteMsg::PickFile, + ), + icon_button( + IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")), + BlockPaletteMsg::CyclePreviewSize, + ), + ] + .spacing(4) + .align_y(iced::Center); + + let body: Element<'_, Message> = if filtered.is_empty() { + let msg = if palette.cached_names.is_empty() { + "No blocks in this drawing" + } else { + "No matches" + }; + container(text(msg).size(12).color(DIM)) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Fill) + .into() + } else { + let mut col = column![].spacing(6); + for chunk in filtered.chunks(palette.preview_size.columns()) { + let mut r = row![].spacing(6).width(Fill); + for block in chunk { + r = r.push(block_card(palette, block)); + } + col = col.push(r); + } + scrollable( + container(col).padding(iced::Padding { + top: 0.0, + right: 8.0, + bottom: 6.0, + left: 0.0, + }), + ) + .width(Fill) + .height(Fill) + .into() + }; + + container(column![title_bar, header, body].spacing(6).padding(6)) + .width(Length::Fixed(PANEL_W)) + .height(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() +} + +fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { + let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); + let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) + .width(Fill) + .height(Length::Fixed(palette.preview_size.box_height())); + // Fixed height guarantees the label occupies exactly one line, so every + // cell in a row keeps the same height even for long names (the elide budget + // handles overflow, and this clips anything that still won't fit). + let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) + .size(11) + .color(TEXT) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(); + let content = column![preview, label].spacing(4).padding(6); + button(content) + .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) + .width(Fill) + .style(move |_: &Theme, status| { + let bg = if is_placing { + CARD_ACTIVE + } else { + match status { + button::Status::Hovered | button::Status::Pressed => CARD_HOVER, + _ => CARD_BG, + } + }; + button::Style { + background: Some(Background::Color(bg)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 3.0.into(), + }, + text_color: TEXT, + ..Default::default() + } + }) + .into() +} + +fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> { + let icon_el: Element<'_, Message> = match icon { + IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(), + IconKind::Svg(bytes) => svg(svg::Handle::from_memory(bytes)) + .width(Length::Fixed(TOOL_H)) + .height(Length::Fixed(TOOL_H)) + .into(), + }; + button(icon_el) + .on_press(Message::BlockPalette(msg)) + .width(Length::Fixed(TOOL_H + 8.0)) + .height(Length::Fixed(TOOL_H + 8.0)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => CARD_HOVER, + _ => Color::TRANSPARENT, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + text_color: Color::WHITE, + ..Default::default() + }) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_size_cycles() { + assert_eq!(cycle_preview_size(PreviewSize::Small), PreviewSize::Medium); + assert_eq!(cycle_preview_size(PreviewSize::Medium), PreviewSize::Large); + assert_eq!(cycle_preview_size(PreviewSize::Large), PreviewSize::Small); + } +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 0877e26de..85dafe04a 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,4 +1,5 @@ pub mod about; +pub mod block_palette; pub mod layout_manager; pub mod layer_state_manager; pub mod plot;