BATTMAN: edit, remove and reorder attribute definitions; references follow (part of #433) (#444)

Squash of PR #444 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:23:32 -04:00
1 parent 7429e7dcbe
commit 77de6af455
6 files changed
+987 -51

No files matched your search

+2 -21
View File
@@ -46,10 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
.interactive(|_| Ok(Box::new(purge::PurgeM::default()))),
CommandSpec::new("blocks.list", "List Blocks", run_list).enabled(has_doc).noundo(),
CommandSpec::new("battman", "Block Attribute Manager...", run_battman)
.menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."])
.params("{name}")
.noundo(),
battman::battman_spec(),
attribs::attdisp_spec(),
attribs::attsync_spec(),
]
@@ -310,23 +307,6 @@ fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
)
}
fn run_battman(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("battman", "`name` is required"))?;
let b = s.doc()?.block(name).cloned().ok_or_else(|| bad("battman", "no such block"))?;
let atts: Vec<Value> = b
.entities
.iter()
.filter_map(|e| {
if let EntityKind::AttDef(a) = &e.kind {
Some(json!({"tag": a.tag, "prompt": a.prompt, "default": a.text.value, "invisible": a.invisible}))
} else {
None
}
})
.collect();
Ok(json!({ "block": name, "attributes": atts }))
}
// ---------------- interactive ----------------
/// -BLOCK's JSON form: BLOCK with the selection erased unless `keep` says otherwise.
@@ -759,3 +739,4 @@ impl Interactive for AttdefM {
}
mod attribs;
mod battman;
+57 -30
View File
@@ -107,9 +107,62 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Ve
ents.filter(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name))).map(|e| e.handle).collect()
}
/// Apply `f` to every reference of block `name` (in model space, layouts and other blocks);
/// returns how many references there were.
fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize {
let mut n = 0;
let mut update = |e: &mut Entity| {
if let EntityKind::Insert(ins) = &mut e.kind {
f(ins);
}
};
let spaces: Vec<cadcraft_doc::Space> =
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
for sp in spaces {
let Some(store) = d.space_mut(&sp) else { continue };
let hs = references(store.iter(), name);
for h in hs {
n += usize::from(store.modify(h, &mut update));
}
}
for (bname, b) in d.blocks.iter_mut() {
if bname.eq_ignore_ascii_case(name) {
continue;
}
let hs = references(b.entities.iter(), name);
if !hs.is_empty() {
let b = Arc::make_mut(b);
for h in hs {
n += usize::from(b.entities.modify(h, &mut update));
}
}
}
n
}
/// How many references block `name` has (in model space, layouts and other blocks).
pub(super) fn reference_count(d: &cadcraft_doc::Drawing, name: &str) -> usize {
let spaces = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities));
spaces
.chain(d.blocks.iter().filter(|(k, _)| !k.eq_ignore_ascii_case(name)).map(|(_, b)| &b.entities))
.map(|st| references(st.iter(), name).len())
.sum()
}
/// Rename the attributes tagged `old` (any case) to `new` on every reference of block `name`, so
/// their values survive a tag change of the definition. Returns how many references there were.
pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> Result<usize> {
let d = s.doc_mut()?;
Ok(update_references(d, name, |ins| {
for a in ins.attribs.iter_mut().filter(|a| a.tag.eq_ignore_ascii_case(old)) {
a.tag = new.to_string();
}
}))
}
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
/// Returns the block's name as defined and how many references were updated.
fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let defs: Vec<&Attrib> = blk
.entities
@@ -120,38 +173,12 @@ fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
})
.collect();
let d = s.doc_mut()?;
let mut n = 0;
let update = |e: &mut Entity| {
if let EntityKind::Insert(ins) = &mut e.kind {
ins.attribs = synced(&blk, &defs, ins);
}
};
let spaces: Vec<cadcraft_doc::Space> =
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
for sp in spaces {
let Some(store) = d.space_mut(&sp) else { continue };
let hs = references(store.iter(), &blk.name);
for h in hs {
n += usize::from(store.modify(h, update));
}
}
for (bname, b) in d.blocks.iter_mut() {
if bname.eq_ignore_ascii_case(&blk.name) {
continue;
}
let hs = references(b.entities.iter(), &blk.name);
if !hs.is_empty() {
let b = Arc::make_mut(b);
for h in hs {
n += usize::from(b.entities.modify(h, update));
}
}
}
let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins));
Ok((blk.name.clone(), n))
}
/// Whether block `name` has attribute definitions.
fn has_attdefs(s: &Session, name: &str) -> bool {
pub(super) fn has_attdefs(s: &Session, name: &str) -> bool {
s.doc().ok().and_then(|d| d.block(name)).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::AttDef(a) if !a.constant)))
}
@@ -171,7 +198,7 @@ fn run_attsync(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Names of the blocks with attribute definitions that match `spec` (comma-separated wildcards).
fn blocks_with_attdefs(s: &Session, spec: &str) -> Vec<String> {
pub(super) fn blocks_with_attdefs(s: &Session, spec: &str) -> Vec<String> {
let Ok(d) = s.doc() else { return Vec::new() };
d.blocks
.values()
+526
View File
@@ -0,0 +1,526 @@
//! BATTMAN: the attribute definitions of a block — listed, edited (tag, prompt, default value,
//! modes, height, layer), removed and reordered. Changes are applied to the existing references
//! (as ATTSYNC does) unless `sync` is false. Tags match in any case, as in AutoCAD.
use std::sync::Arc;
use cadcraft_doc::{Attrib, Block, EntityKind, EntityStore, Handle};
use serde_json::{Value, json};
use super::attribs::{blocks_with_attdefs, has_attdefs, reference_count, rename_tag, sync};
use crate::cmd::curves::KW;
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn battman_spec() -> CommandSpec {
CommandSpec::new("battman", "Block Attribute Manager...", run)
.menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."])
.params(
"{name, action?: \"list\" (default) | \"edit\" | \"remove\" | \"up\" | \"down\" | \"order\" | \"sync\", tag? (the definition to edit, remove or move; any case), newTag?, prompt?, default?, invisible?, constant?, height?, layer?, order?: [every tag, in the new order], sync?: bool (default true: existing references follow, as with ATTSYNC)} → {block, attributes: [{tag, prompt, default, invisible, constant, height, layer}], references, synced?}",
)
.interactive(|s| Ok(Box::new(BattmanM::new(s))))
}
/// A change to one attribute definition; `None` leaves the property as it is.
#[derive(Clone, Debug, Default)]
struct Change {
tag: Option<String>,
prompt: Option<String>,
default: Option<String>,
invisible: Option<bool>,
constant: Option<bool>,
height: Option<f64>,
layer: Option<String>,
}
/// The attribute definitions of `b` in order: handle, definition, layer.
fn defs(b: &Block) -> Vec<(Handle, &Attrib, &str)> {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(a) => Some((e.handle, a, e.common.layer.as_str())),
_ => None,
})
.collect()
}
/// The definition tagged `tag` (any case) in `b`.
fn find(b: &Block, tag: &str) -> Result<Handle> {
defs(b)
.into_iter()
.find(|(_, a, _)| a.tag.eq_ignore_ascii_case(tag.trim()))
.map(|(h, ..)| h)
.ok_or_else(|| EngineError::Other(format!("Block \"{}\" has no attribute tagged \"{}\".", b.name, tag.trim())))
}
fn block(s: &Session, name: &str) -> Result<Arc<Block>> {
s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))
}
/// The block's attribute definitions and how many references it has.
fn listing(s: &Session, name: &str) -> Result<Value> {
let b = block(s, name)?;
let atts: Vec<Value> = defs(&b)
.into_iter()
.map(|(_, a, layer)| {
json!({"tag": a.tag, "prompt": a.prompt, "default": a.text.value, "invisible": a.invisible, "constant": a.constant, "height": a.text.height, "layer": layer})
})
.collect();
Ok(json!({ "block": b.name, "attributes": atts, "references": reference_count(s.doc()?, &b.name) }))
}
/// Replace block `name` with `f` applied to a copy of it; nothing changes when `f` fails.
fn update_block(s: &mut Session, name: &str, f: impl FnOnce(&mut Block) -> Result<()>) -> Result<()> {
let mut b = (*block(s, name)?).clone();
f(&mut b)?;
let d = s.doc_mut()?;
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| b.name.clone());
d.blocks.insert(key, Arc::new(b));
Ok(())
}
/// A tag as attribute definitions store it: non-empty, without spaces, in capitals.
fn valid_tag(tag: &str) -> Result<String> {
let t = tag.trim();
if t.is_empty() || t.contains(char::is_whitespace) {
return Err(EngineError::Other("A tag must be non-empty and without spaces.".into()));
}
Ok(t.to_ascii_uppercase())
}
/// Apply `c` to the definition tagged `tag` of block `name`. Returns the tag it has afterwards.
fn edit(s: &mut Session, name: &str, tag: &str, c: &Change) -> Result<String> {
let new_tag = c.tag.as_deref().map(valid_tag).transpose()?;
if let Some(h) = c.height
&& !(h.is_finite() && h > 0.0)
{
return Err(EngineError::Other("The text height must be a positive number.".into()));
}
let layer = match &c.layer {
Some(l) => {
Some(s.doc()?.layer(l.trim()).map(|l| l.name.clone()).ok_or_else(|| EngineError::Other(format!("Layer \"{}\" not found.", l.trim())))?)
}
None => None,
};
let mut result = String::new();
update_block(s, name, |b| {
let h = find(b, tag)?;
if let Some(t) = &new_tag
&& defs(b).iter().any(|(o, a, _)| *o != h && a.tag.eq_ignore_ascii_case(t))
{
return Err(EngineError::Other(format!("Block \"{}\" already has an attribute tagged \"{t}\".", b.name)));
}
b.entities.modify(h, |e| {
if let Some(l) = &layer {
e.common.layer = l.clone();
}
if let EntityKind::AttDef(a) = &mut e.kind {
if let Some(t) = &new_tag {
a.tag = t.clone();
}
if let Some(p) = &c.prompt {
a.prompt = p.clone();
}
if let Some(v) = &c.default {
a.text.value = v.clone();
}
if let Some(v) = c.invisible {
a.invisible = v;
}
if let Some(v) = c.constant {
a.constant = v;
}
if let Some(v) = c.height {
a.text.height = v;
}
result = a.tag.clone();
}
});
Ok(())
})?;
Ok(result)
}
/// Put the attribute definitions of `b` in the order `order` (handles of all of them), leaving
/// every other object where it is.
fn reorder(b: &mut Block, order: &[Handle]) {
let mut next = order.iter();
let mut store = EntityStore::new();
for e in b.entities.iter() {
let h = if matches!(e.kind, EntityKind::AttDef(_)) { next.next().copied().unwrap_or(e.handle) } else { e.handle };
if let Some(e) = b.entities.get(h) {
store.push((**e).clone());
}
}
b.entities = store;
}
/// Move the definition tagged `tag` one place up (`by` -1) or down (+1); at either end nothing moves.
fn shift(s: &mut Session, name: &str, tag: &str, by: isize) -> Result<()> {
update_block(s, name, |b| {
let h = find(b, tag)?;
let mut order: Vec<Handle> = defs(b).iter().map(|(h, ..)| *h).collect();
let Some(i) = order.iter().position(|o| *o == h) else { return Ok(()) };
if let Some(j) = i.checked_add_signed(by).filter(|j| *j < order.len()) {
order.swap(i, j);
reorder(b, &order);
}
Ok(())
})
}
/// Put the definitions in the order of `tags`, which must name each of them once.
fn set_order(s: &mut Session, name: &str, tags: &[String]) -> Result<()> {
update_block(s, name, |b| {
let all = defs(b).len();
let mut order: Vec<Handle> = Vec::with_capacity(all);
for t in tags.iter().take(all.saturating_add(1)) {
let h = find(b, t)?;
if order.contains(&h) {
return Err(EngineError::Other(format!("\"{t}\" is listed twice.")));
}
order.push(h);
}
if order.len() != all {
return Err(EngineError::Other(format!("The order must list each of the {all} attribute tags once.")));
}
reorder(b, &order);
Ok(())
})
}
fn remove(s: &mut Session, name: &str, tag: &str) -> Result<()> {
update_block(s, name, |b| {
let h = find(b, tag)?;
b.entities.remove(h);
Ok(())
})
}
/// Existing references follow the definitions: attributes renamed from `renamed` (old, new) keep
/// their values. Returns how many references were updated.
fn apply(s: &mut Session, name: &str, renamed: Option<(&str, &str)>) -> Result<usize> {
if let Some((old, new)) = renamed.filter(|(o, n)| !o.eq_ignore_ascii_case(n)) {
rename_tag(s, name, old, new)?;
}
Ok(sync(s, name)?.1)
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("battman", "`name` is required"))?.to_string();
let name = block(s, &name)?.name.clone();
let action = str_param(p, "action").unwrap_or("list").to_ascii_lowercase();
let tag = || str_param(p, "tag").map(str::to_string).ok_or_else(|| bad("battman", format!("`tag` is required for \"{action}\"")));
let mut renamed = None;
match action.as_str() {
"list" => return listing(s, &name),
"sync" => {}
"edit" => {
let opt_str = |k| str_param(p, k).map(str::to_string);
let opt_bool = |k| p.get(k).and_then(Value::as_bool);
let height = match p.get("height") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().ok_or_else(|| bad("battman", "`height` must be a number"))?),
};
let c = Change {
tag: opt_str("newTag"),
prompt: opt_str("prompt"),
default: opt_str("default"),
invisible: opt_bool("invisible"),
constant: opt_bool("constant"),
height,
layer: opt_str("layer"),
};
let old = tag()?;
let new = edit(s, &name, &old, &c)?;
renamed = Some((old, new));
}
"remove" => remove(s, &name, &tag()?)?,
"up" => shift(s, &name, &tag()?, -1)?,
"down" => shift(s, &name, &tag()?, 1)?,
"order" => {
let tags: Vec<String> = p
.get("order")
.and_then(Value::as_array)
.ok_or_else(|| bad("battman", "`order` (an array of tags) is required"))?
.iter()
.map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad("battman", "`order` must hold tags (strings)")))
.collect::<Result<_>>()?;
set_order(s, &name, &tags)?;
}
other => return Err(bad("battman", format!("unknown action \"{other}\" (list, edit, remove, up, down, order or sync)"))),
}
let synced = if action == "sync" || bool_or(p, "sync", true) {
Some(apply(s, &name, renamed.as_ref().map(|(o, n)| (o.as_str(), n.as_str())))?)
} else {
None
};
let mut r = listing(s, &name)?;
if let (Some(n), Some(o)) = (synced, r.as_object_mut()) {
o.insert("synced".into(), json!(n));
}
Ok(r)
}
// ---------------- command line ----------------
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Edit,
Remove,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Prop {
Tag,
Prompt,
Default,
Invisible,
Constant,
Height,
Layer,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Stage {
Block,
Option,
Tag(Op),
Prop,
Value(Prop),
}
/// BATTMAN typed at a command line without the dialog: a block name, then options to edit,
/// remove, move and synchronize its attribute definitions. Every change reaches the existing
/// references at once (the dialog's default).
struct BattmanM {
stage: Stage,
block: String,
tag: String,
}
const OPTIONS: &[&str] = &["Edit", "Remove", "moveUp", "moveDown", "Sync", "List", "eXit"];
const PROPS: &[&str] = &["Tag", "Prompt", "Default", "Invisible", "Constant", "Height", "Layer", "eXit"];
impl BattmanM {
fn new(s: &Session) -> Self {
// A selected block reference with attributes names the block.
let picked = s.selection().into_iter().find_map(|h| match s.doc().ok()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(i)) if has_attdefs(s, &i.block) => Some(i.block.clone()),
_ => None,
});
match picked {
Some(b) => BattmanM { stage: Stage::Option, block: b, tag: String::new() },
None => BattmanM { stage: Stage::Block, block: String::new(), tag: String::new() },
}
}
/// Echo the block's attribute definitions, one per line.
fn list(&self, s: &mut Session) -> Result<()> {
let b = block(s, &self.block)?;
let n = reference_count(s.doc()?, &b.name);
s.echo(format!("Block {}: {n} reference{} in the drawing.", b.name, if n == 1 { "" } else { "s" }));
let lines: Vec<String> = defs(&b)
.into_iter()
.map(|(_, a, _)| {
let mut modes: Vec<&str> = Vec::new();
if a.invisible {
modes.push("invisible");
}
if a.constant {
modes.push("constant");
}
let modes = if modes.is_empty() { String::new() } else { format!(" ({})", modes.join(", ")) };
format!(" {} prompt \"{}\" default \"{}\"{modes}", a.tag, a.prompt, a.text.value)
})
.collect();
for l in lines {
s.echo(l);
}
Ok(())
}
/// The definition being edited (by its tag).
fn current(&self, s: &Session) -> Option<Attrib> {
let b = s.doc().ok()?.block(&self.block)?.clone();
defs(&b).into_iter().find(|(_, a, _)| a.tag.eq_ignore_ascii_case(&self.tag)).map(|(_, a, _)| a.clone())
}
fn current_layer(&self, s: &Session) -> String {
let Some(b) = s.doc().ok().and_then(|d| d.block(&self.block)).cloned() else { return "0".into() };
defs(&b).into_iter().find(|(_, a, _)| a.tag.eq_ignore_ascii_case(&self.tag)).map(|(.., l)| l.to_string()).unwrap_or_else(|| "0".into())
}
/// Edit the current definition with `c` and bring the references in line.
fn change(&mut self, s: &mut Session, c: Change) -> Result<()> {
let old = self.tag.clone();
let name = self.block.clone();
let new = edit(s, &name, &old, &c)?;
apply(s, &name, Some((&old, &new)))?;
self.tag = new;
Ok(())
}
}
/// `v` with at most four decimals and no trailing zeros.
fn num(v: f64) -> String {
let t = format!("{v:.4}");
if t.contains('.') { t.trim_end_matches('0').trim_end_matches('.').to_string() } else { t }
}
/// Yes or No typed or picked as a keyword.
fn yes(i: &Input) -> Option<bool> {
match i {
Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().chars().next() {
Some('y') => Some(true),
Some('n') => Some(false),
_ => None,
},
_ => None,
}
}
impl Interactive for BattmanM {
fn name(&self) -> &'static str {
"BATTMAN"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if self.stage == Stage::Option {
s.set_selection(Vec::new());
self.list(s)?;
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
let yes_no = |v: bool| if v { "Yes" } else { "No" };
match self.stage {
Stage::Block => Prompt::new("Enter name of block to manage", Accept::TEXT).kw(&["?"]),
Stage::Option => Prompt::new(format!("Block {}: enter an option", self.block), KW).kw(OPTIONS).default("eXit"),
Stage::Tag(_) => Prompt::new("Enter attribute tag", Accept::TEXT),
Stage::Prop => Prompt::new(format!("Attribute {}: enter property to change", self.tag), KW).kw(PROPS).default("eXit"),
Stage::Value(p) => {
let a = self.current(s);
let text = |f: fn(&Attrib) -> String| a.as_ref().map(f).unwrap_or_default();
match p {
Prop::Tag => Prompt::new("Enter new tag", Accept::TEXT).default(self.tag.clone()),
Prop::Prompt => Prompt::new("Enter new prompt", Accept::TEXT).default(text(|a| a.prompt.clone())),
Prop::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text(|a| a.text.value.clone())),
Prop::Invisible => Prompt::new("Invisible?", KW).kw(&["Yes", "No"]).default(yes_no(a.as_ref().is_some_and(|a| a.invisible))),
Prop::Constant => Prompt::new("Constant?", KW).kw(&["Yes", "No"]).default(yes_no(a.as_ref().is_some_and(|a| a.constant))),
Prop::Height => Prompt::new("Specify text height", Accept::NUMBER).default(num(a.as_ref().map_or(0.0, |a| a.text.height))),
Prop::Layer => Prompt::new("Enter layer name", Accept::TEXT).default(self.current_layer(s)),
}
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.stage {
Stage::Block => match &i {
Input::Keyword(k) if k == "?" => {
let names = blocks_with_attdefs(s, "*");
s.echo(if names.is_empty() {
"No blocks with attributes.".to_string()
} else {
format!("Blocks with attributes: {}", names.join(", "))
});
}
Input::Text(t) if !t.trim().is_empty() => {
let b = block(s, t.trim())?;
if !b.entities.iter().any(|e| matches!(e.kind, EntityKind::AttDef(_))) {
return Err(EngineError::Other(format!("Block \"{}\" has no attribute definitions.", b.name)));
}
self.block = b.name.clone();
self.stage = Stage::Option;
self.list(s)?;
}
Input::Enter => return Ok(Step::Done),
_ => {}
},
Stage::Option => match &i {
Input::Keyword(k) => match k.as_str() {
"Edit" => self.stage = Stage::Tag(Op::Edit),
"Remove" => self.stage = Stage::Tag(Op::Remove),
"moveUp" => self.stage = Stage::Tag(Op::Up),
"moveDown" => self.stage = Stage::Tag(Op::Down),
"Sync" => {
let (name, n) = sync(s, &self.block)?;
s.echo(format!("ATTSYNC block {name}: {n} reference{} synchronized.", if n == 1 { "" } else { "s" }));
}
"List" => self.list(s)?,
_ => return Ok(Step::Done),
},
Input::Enter => return Ok(Step::Done),
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
},
Stage::Tag(op) => match &i {
Input::Text(t) if !t.trim().is_empty() => {
let name = self.block.clone();
let b = block(s, &name)?;
let h = find(&b, t)?;
let tag = defs(&b).into_iter().find(|(o, ..)| *o == h).map(|(_, a, _)| a.tag.clone()).unwrap_or_default();
match op {
Op::Edit => {
self.tag = tag;
self.stage = Stage::Prop;
return Ok(Step::Continue);
}
Op::Remove => remove(s, &name, &tag)?,
Op::Up => shift(s, &name, &tag, -1)?,
Op::Down => shift(s, &name, &tag, 1)?,
}
apply(s, &name, None)?;
self.stage = Stage::Option;
}
Input::Enter => self.stage = Stage::Option,
_ => {}
},
Stage::Prop => match &i {
Input::Keyword(k) => {
self.stage = match k.as_str() {
"Tag" => Stage::Value(Prop::Tag),
"Prompt" => Stage::Value(Prop::Prompt),
"Default" => Stage::Value(Prop::Default),
"Invisible" => Stage::Value(Prop::Invisible),
"Constant" => Stage::Value(Prop::Constant),
"Height" => Stage::Value(Prop::Height),
"Layer" => Stage::Value(Prop::Layer),
_ => Stage::Option,
}
}
Input::Enter => self.stage = Stage::Option,
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
},
Stage::Value(p) => {
if i == Input::Enter {
self.stage = Stage::Prop;
return Ok(Step::Continue);
}
let text = match &i {
Input::Text(t) | Input::Keyword(t) => t.clone(),
_ => return Ok(Step::Continue),
};
let c = match p {
Prop::Tag => Change { tag: Some(text), ..Change::default() },
Prop::Prompt => Change { prompt: Some(text), ..Change::default() },
Prop::Default => Change { default: Some(text), ..Change::default() },
Prop::Invisible => {
Change { invisible: Some(yes(&i).ok_or_else(|| EngineError::Other("Enter Yes or No.".into()))?), ..Change::default() }
}
Prop::Constant => {
Change { constant: Some(yes(&i).ok_or_else(|| EngineError::Other("Enter Yes or No.".into()))?), ..Change::default() }
}
Prop::Height => {
let h = crate::units::parse_distance(&text).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))?;
Change { height: Some(h), ..Change::default() }
}
Prop::Layer => Change { layer: Some(text), ..Change::default() },
};
self.change(s, c)?;
self.stage = Stage::Prop;
}
}
Ok(Step::Continue)
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Attribute tools: BATTMAN editing (definitions edited, removed, reordered; references follow).
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_engine::Session;
use serde_json::{Value, json};
/// Block B with attribute definitions A, B, C (defaults a, b, c) and a line, plus two references.
fn drawing() -> (Session, Handle, Handle) {
let mut s = Session::new();
for (i, t) in ["A", "B", "C"].iter().enumerate() {
let y = i as f64 * 2.0;
s.execute("attdef", &json!({"tag": t, "prompt": format!("{t}?"), "default": t.to_ascii_lowercase(), "at": [0, y], "height": 1.0})).unwrap();
}
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
let r1 = insert(&mut s, 0.0, json!({"A": "one", "B": "two"}));
let r2 = insert(&mut s, 50.0, json!({}));
(s, r1, r2)
}
fn insert(s: &mut Session, x: f64, attribs: Value) -> Handle {
let r = s.execute("insert", &json!({"name": "B", "at": [x, 0], "attribs": attribs})).unwrap();
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
}
/// (tag, value, invisible) of a reference's attributes, in order.
fn attribs(s: &Session, h: Handle) -> Vec<(String, String, bool)> {
let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };
i.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone(), a.invisible)).collect()
}
fn tags(r: &Value) -> Vec<&str> {
r["attributes"].as_array().unwrap().iter().map(|a| a["tag"].as_str().unwrap()).collect()
}
fn battman(s: &mut Session, p: Value) -> Value {
s.execute("battman", &p).unwrap()
}
#[test]
fn battman_lists_definitions_and_references() {
let (mut s, ..) = drawing();
let r = battman(&mut s, json!({"name": "b"}));
assert_eq!(r["block"], "B");
assert_eq!(r["references"], 2);
assert_eq!(tags(&r), ["A", "B", "C"]);
assert_eq!(
r["attributes"][0],
json!({"tag": "A", "prompt": "A?", "default": "a", "invisible": false, "constant": false, "height": 1.0, "layer": "0"})
);
// Listing changes nothing, so it leaves no undo step.
let undo = s.state().unwrap().undo.len();
battman(&mut s, json!({"name": "B", "action": "list"}));
assert_eq!(s.state().unwrap().undo.len(), undo);
}
#[test]
fn battman_edit_renames_a_tag_and_references_keep_their_values() {
let (mut s, r1, r2) = drawing();
let r = battman(
&mut s,
json!({"name": "B", "action": "edit", "tag": "a", "newTag": "part", "prompt": "Part number", "default": "P-0", "invisible": true}),
);
assert_eq!(r["synced"], 2);
assert_eq!(
r["attributes"][0],
json!({"tag": "PART", "prompt": "Part number", "default": "P-0", "invisible": true, "constant": false, "height": 1.0, "layer": "0"})
);
let a = attribs(&s, r1);
assert_eq!(a[0], ("PART".into(), "one".into(), true));
assert_eq!(attribs(&s, r2)[0], ("PART".into(), "a".into(), true));
// A new reference gets the new default.
let r3 = insert(&mut s, 100.0, json!({}));
assert_eq!(attribs(&s, r3)[0].1, "P-0");
// One undo step (after the INSERT's) puts everything back.
s.undo().unwrap();
s.undo().unwrap();
assert_eq!(attribs(&s, r1)[0], ("A".into(), "one".into(), false));
}
#[test]
fn battman_without_sync_leaves_references_alone() {
let (mut s, r1, _) = drawing();
let r = battman(&mut s, json!({"name": "B", "action": "edit", "tag": "B", "invisible": true, "sync": false}));
assert!(r.get("synced").is_none());
assert_eq!(r["attributes"][1]["invisible"], true);
assert_eq!(attribs(&s, r1)[1], ("B".into(), "two".into(), false));
// Sync applies it later.
assert_eq!(battman(&mut s, json!({"name": "B", "action": "sync"}))["synced"], 2);
assert!(attribs(&s, r1)[1].2);
}
#[test]
fn battman_removes_and_reorders_definitions() {
let (mut s, r1, _) = drawing();
let r = battman(&mut s, json!({"name": "B", "action": "down", "tag": "a"}));
assert_eq!(tags(&r), ["B", "A", "C"]);
assert_eq!(attribs(&s, r1).iter().map(|a| a.1.as_str()).collect::<Vec<_>>(), ["two", "one", "c"]);
// Already at the top: nothing moves.
assert_eq!(tags(&battman(&mut s, json!({"name": "B", "action": "up", "tag": "B"}))), ["B", "A", "C"]);
assert_eq!(tags(&battman(&mut s, json!({"name": "B", "action": "order", "order": ["c", "a", "b"]}))), ["C", "A", "B"]);
let r = battman(&mut s, json!({"name": "B", "action": "remove", "tag": "A"}));
assert_eq!(tags(&r), ["C", "B"]);
assert_eq!(attribs(&s, r1).iter().map(|a| a.0.as_str()).collect::<Vec<_>>(), ["C", "B"]);
// The block's other objects are still there.
let d = s.doc().unwrap();
assert!(d.block("B").unwrap().entities.iter().any(|e| matches!(e.kind, EntityKind::Line(_))));
}
#[test]
fn battman_constant_definitions_leave_references() {
let (mut s, r1, _) = drawing();
battman(&mut s, json!({"name": "B", "action": "edit", "tag": "C", "constant": true}));
assert_eq!(attribs(&s, r1).len(), 2);
battman(&mut s, json!({"name": "B", "action": "edit", "tag": "C", "constant": false}));
assert_eq!(attribs(&s, r1)[2], ("C".into(), "c".into(), false));
}
#[test]
fn battman_refuses_bad_edits_without_changing_anything() {
let (mut s, ..) = drawing();
let before = s.doc().unwrap().clone();
for p in [
json!({}),
json!({"name": "NOPE"}),
json!({"name": "B", "action": "explode"}),
json!({"name": "B", "action": "edit"}),
json!({"name": "B", "action": "edit", "tag": "Z"}),
json!({"name": "B", "action": "edit", "tag": "A", "newTag": "b"}),
json!({"name": "B", "action": "edit", "tag": "A", "newTag": "two words"}),
json!({"name": "B", "action": "edit", "tag": "A", "newTag": ""}),
json!({"name": "B", "action": "edit", "tag": "A", "height": -1}),
json!({"name": "B", "action": "edit", "tag": "A", "height": "tall"}),
json!({"name": "B", "action": "edit", "tag": "A", "layer": "NOPE"}),
json!({"name": "B", "action": "order", "order": ["A", "B"]}),
json!({"name": "B", "action": "order", "order": ["A", "A", "B", "C"]}),
json!({"name": "B", "action": "order", "order": vec!["A"; 100_000]}),
json!({"name": "B", "action": "order", "order": [1, 2, 3]}),
json!({"name": "B", "action": "remove", "tag": "Z"}),
] {
assert!(s.execute("battman", &p).is_err(), "{p}");
assert_eq!(*s.doc().unwrap(), before, "{p}");
}
}
#[test]
fn battman_at_the_command_line_edits_removes_and_moves() {
let (mut s, r1, _) = drawing();
s.cmdline("battman").unwrap();
assert!(s.prompt_text().contains("Enter name of block to manage or [?]:"), "{}", s.prompt_text());
s.cmdline("b").unwrap();
assert!(s.log.iter().any(|l| l == "Block B: 2 references in the drawing."), "{:?}", s.log);
assert!(s.prompt_text().contains("[Edit/Remove/moveUp/moveDown/Sync/List/eXit] <eXit>"), "{}", s.prompt_text());
for l in ["e", "a", "t", "ITEM", "p", "Item name", "i", "y", "h", "2", "x"] {
s.cmdline(l).unwrap();
}
let r = battman(&mut s, json!({"name": "B"}));
assert_eq!(
r["attributes"][0],
json!({"tag": "ITEM", "prompt": "Item name", "default": "a", "invisible": true, "constant": false, "height": 2.0, "layer": "0"})
);
assert_eq!(attribs(&s, r1)[0], ("ITEM".into(), "one".into(), true));
for l in ["d", "item", "r", "c"] {
s.cmdline(l).unwrap();
}
assert_eq!(attribs(&s, r1).iter().map(|a| a.0.as_str()).collect::<Vec<_>>(), ["B", "ITEM"]);
s.cmdline("").unwrap();
assert!(s.running.is_none());
}
#[test]
fn battman_starts_from_a_selected_reference() {
let (mut s, r1, _) = drawing();
s.set_selection(vec![r1]);
s.cmdline("battman").unwrap();
assert!(s.prompt_text().contains("Block B: enter an option"), "{}", s.prompt_text());
s.cmdline("x").unwrap();
assert!(s.running.is_none());
}
+5
View File
@@ -14,6 +14,9 @@ use crate::CadApp;
use crate::parametric::expr_field;
use crate::theme::Tokens;
/// The Block Attribute Manager (BATTMAN).
mod battman;
/// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the
/// call carries parameters.
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
@@ -21,6 +24,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
return None;
}
let dialog = match id {
"battman" => "battman",
"linetype" | "lt" | "ltype" => "linetype",
"ddptype" => "ptype",
"tablestyle" | "ts" => "tablestyle",
@@ -40,6 +44,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
/// Show the dialog named `name`; closes it (`open = false`) if it isn't one of these.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool) {
let action = match name {
"battman" => battman::dialog(app, ctx, open),
"linetype" => linetype(app, ctx, open),
"ptype" => point_style(app, ctx, open),
"tablestyle" => table_style(app, ctx, open),
+217
View File
@@ -0,0 +1,217 @@
//! Block Attribute Manager (BATTMAN): a block's attribute definitions in a list, with buttons to
//! synchronize, move and remove them and fields to edit the selected one. Every change runs the
//! `battman` command (`app.run`), so it is undoable and scriptable.
use cadcraft_doc::EntityKind;
use egui::{RichText, vec2};
use serde_json::{Value, json};
use crate::CadApp;
use crate::theme::Tokens;
/// One attribute definition as listed.
struct Row {
tag: String,
prompt: String,
default: String,
invisible: bool,
constant: bool,
height: f64,
layer: String,
}
fn temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str) -> Option<T> {
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::new(id)))
}
fn set_temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str, v: T) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v));
}
/// A text field committing on Enter / focus loss when changed (an empty value too).
fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option<String> {
let mut buf = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| value.to_string());
let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0));
if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(id));
}
(resp.lost_focus() && buf != value).then_some(buf)
}
fn fmt_num(v: f64) -> String {
let s = format!("{v:.4}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
}
/// Show the dialog; returns the command to run.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<(&'static str, Value)> {
let t = Tokens::get();
let Ok(d) = app.session.doc() else {
egui::Window::new("Block Attribute Manager").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.label("Open a drawing first.");
});
return None;
};
let mut blocks: Vec<String> = d
.blocks
.values()
.filter(|b| !b.anonymous && b.entities.iter().any(|e| matches!(e.kind, EntityKind::AttDef(_))))
.map(|b| b.name.clone())
.collect();
blocks.sort_by_key(|n| n.to_ascii_uppercase());
let layers: Vec<String> = d.layers.iter().map(|l| l.name.clone()).collect();
let mut block = temp::<String>(ctx, "battman_block").filter(|b| blocks.contains(b)).or_else(|| blocks.first().cloned()).unwrap_or_default();
let rows: Vec<Row> = d
.block(&block)
.map(|b| {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(a) => Some(Row {
tag: a.tag.clone(),
prompt: a.prompt.clone(),
default: a.text.value.clone(),
invisible: a.invisible,
constant: a.constant,
height: a.text.height,
layer: e.common.layer.clone(),
}),
_ => None,
})
.collect()
})
.unwrap_or_default();
let refs = count_references(d, &block);
let mut sel = temp::<String>(ctx, "battman_sel").filter(|s| rows.iter().any(|r| r.tag == *s)).or_else(|| rows.first().map(|r| r.tag.clone()));
let mut apply = temp::<bool>(ctx, "battman_apply").unwrap_or(true);
let mut action: Option<(&'static str, Value)> = None;
let act = |p: Value, apply: bool| {
let mut p = p;
if let Some(o) = p.as_object_mut() {
o.insert("sync".into(), json!(apply));
}
Some(("battman", p))
};
egui::Window::new("Block Attribute Manager").open(open).default_width(460.0).resizable(false).show(ctx, |ui| {
if blocks.is_empty() {
ui.label("This drawing has no blocks with attributes.");
return;
}
ui.horizontal(|ui| {
ui.label("Block:");
egui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| {
for b in &blocks {
if ui.selectable_label(*b == block, b).clicked() {
block = b.clone();
sel = None;
}
}
});
ui.label(RichText::new(format!("Found in drawing: {refs}")).color(t.text_dim));
});
ui.separator();
{
egui::ScrollArea::vertical().max_height(180.0).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("battman_grid").striped(true).num_columns(4).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
for h in ["Tag", "Prompt", "Default", "Modes"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for r in &rows {
if ui.selectable_label(sel.as_deref() == Some(r.tag.as_str()), &r.tag).clicked() {
sel = Some(r.tag.clone());
}
ui.label(&r.prompt);
ui.label(&r.default);
let modes = [(r.invisible, "I"), (r.constant, "C")].iter().filter(|(on, _)| *on).map(|(_, m)| *m).collect::<String>();
ui.label(RichText::new(modes).color(t.text_dim));
ui.end_row();
}
});
});
ui.horizontal(|ui| {
if ui.button("Sync").on_hover_text("Bring every reference in line with these definitions").clicked() {
action = act(json!({ "name": block, "action": "sync" }), true);
}
let picked = sel.clone().unwrap_or_default();
let i = rows.iter().position(|r| r.tag == picked);
if ui.add_enabled(i.is_some_and(|i| i > 0), egui::Button::new("Move Up")).clicked() {
action = act(json!({ "name": block, "action": "up", "tag": picked }), apply);
}
if ui.add_enabled(i.is_some_and(|i| i + 1 < rows.len()), egui::Button::new("Move Down")).clicked() {
action = act(json!({ "name": block, "action": "down", "tag": picked }), apply);
}
if ui.add_enabled(i.is_some(), egui::Button::new("Remove")).clicked() {
action = act(json!({ "name": block, "action": "remove", "tag": picked }), apply);
}
});
}
ui.separator();
if let Some(r) = sel.as_ref().and_then(|s| rows.iter().find(|r| r.tag == *s)) {
ui.label(RichText::new(format!("Edit attribute {}", r.tag)).strong());
let edit = |key: &str, v: Value| json!({ "name": block, "action": "edit", "tag": r.tag, key: v });
egui::Grid::new("battman_edit").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Tag");
if let Some(v) = text_field(ui, egui::Id::new(("battman_tag", &block, &r.tag)), &r.tag) {
action = act(edit("newTag", json!(v)), apply);
sel = Some(v.trim().to_ascii_uppercase());
}
ui.end_row();
ui.label("Prompt");
if let Some(v) = text_field(ui, egui::Id::new(("battman_prompt", &block, &r.tag)), &r.prompt) {
action = act(edit("prompt", json!(v)), apply);
}
ui.end_row();
ui.label("Default");
if let Some(v) = text_field(ui, egui::Id::new(("battman_default", &block, &r.tag)), &r.default) {
action = act(edit("default", json!(v)), apply);
}
ui.end_row();
ui.label("Mode");
ui.horizontal(|ui| {
let (mut inv, mut con) = (r.invisible, r.constant);
if ui.checkbox(&mut inv, "Invisible").changed() {
action = act(edit("invisible", json!(inv)), apply);
}
if ui.checkbox(&mut con, "Constant").changed() {
action = act(edit("constant", json!(con)), apply);
}
});
ui.end_row();
ui.label("Height");
let height = text_field(ui, egui::Id::new(("battman_height", &block, &r.tag)), &fmt_num(r.height));
if let Some(h) = height.and_then(|v| v.trim().parse::<f64>().ok()).filter(|h| h.is_finite() && *h > 0.0) {
action = act(edit("height", json!(h)), apply);
}
ui.end_row();
ui.label("Layer");
egui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| {
for l in &layers {
if ui.selectable_label(*l == r.layer, l).clicked() && *l != r.layer {
action = act(edit("layer", json!(l)), apply);
}
}
});
ui.end_row();
});
}
ui.separator();
ui.checkbox(&mut apply, "Apply changes to existing references");
});
set_temp(ctx, "battman_block", block);
if let Some(s) = sel {
set_temp(ctx, "battman_sel", s);
}
set_temp(ctx, "battman_apply", apply);
action
}
/// References of block `name` in model space, the layouts and other blocks.
fn count_references(d: &cadcraft_doc::Drawing, name: &str) -> usize {
let is_ref = |e: &std::sync::Arc<cadcraft_doc::Entity>| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name));
let spaces = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities));
let blocks = d.blocks.iter().filter(|(k, _)| !k.eq_ignore_ascii_case(name)).map(|(_, b)| &b.entities);
spaces.chain(blocks).map(|st| st.iter().filter(|e| is_ref(e)).count()).sum()
}