mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #444 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7429e7dcbe
commit
77de6af455
6 files changed
+987
-51
No files matched your search
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user