feat(insert): add docked block palette

Add a searchable block palette with previews, file import, collision-safe nested definitions, and dependency/table-record import. Keep palette thumbnails current across drawing switches and block-definition edits.
This commit is contained in:
Karim Jerbi authored and Hakan Seven committed 2026-08-06 11:02:52 +03:00
1 parent 67a767e23b
commit 8f009d0058
13 files changed
+1248 -22

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="#cccccc" stroke-width="1.4"/>
<rect x="12.5" y="3" width="8.5" height="7" rx="1" stroke="#cccccc" stroke-width="1.4"/>
<rect x="3" y="12.5" width="18" height="8.5" rx="1" stroke="#4a9eff" stroke-width="1.4"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

+15 -6
View File
@@ -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
+109 -12
View File
@@ -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 <name>: {}",
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
);
}
}
+1 -1
View File
@@ -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 {
+11
View File
@@ -541,6 +541,12 @@ pub(super) struct OpenCADStudio {
render_mode_preview: Option<acadrust::entities::ViewportRenderMode>,
/// 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,
+633
View File
@@ -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<String, String> {
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<String, String> {
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<acadrust::EntityType> = 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<String, String> =
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<String> = 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<Message> {
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<String> {
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");
}
}
+7
View File
@@ -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
+34 -1
View File
@@ -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(
+3 -2
View File
@@ -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()),
}
+51
View File
@@ -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<WireModel> {
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());
}
}
+1
View File
@@ -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<String, String> {
+377
View File
@@ -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<std::path::PathBuf, String>),
}
/// 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<WireModel>,
}
/// 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<BlockEntry>,
/// Block name currently being placed (INSERT active), for the highlight.
pub placing: Option<String>,
/// Last known block-name list, for the cheap stale check.
pub cached_names: Vec<String>,
/// 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<u64>,
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<Message> for BlockPreviewCanvas<'a> {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<canvas::Geometry> {
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<iced::Point> = Vec::new();
let flush = |frame: &mut Frame, run: &mut Vec<iced::Point>, 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);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod about;
pub mod block_palette;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod plot;