ATTEDIT / EATTEDIT typed at the command line ran the JSON form and failed with "`handle` is required". It now asks "Select block reference:" and then "Enter value for <TAG> <current>:" for each attribute (Enter keeps the value); the answers are applied together as one undo step. -ATTEDIT is a command of its own (Modify > Object > Attribute > Global): one at a time (Value: Change/Replace, Next) or global string replacement, over the attributes chosen by block name, tag and value specifications. Session::start now prefers an id that keeps its leading dash, so -ATTEDIT is not taken for ATTEDIT; dashed aliases (-INSERT...) resolve as before. INSERT `attribs` and ATTEDIT `values` match attribute tags in any case and report the keys no attribute has in `unknownTags`. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b286119d2
commit
7a876c74c9
|
|
@ -11,6 +11,8 @@ use super::machines::{SelOutcome, SelectPhase, number};
|
|||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("block", "Make...", run_block)
|
||||
|
|
@ -33,10 +35,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.alias(&["att", "-attdef"])
|
||||
.params("{tag, prompt?, default?, at, height?, invisible?}")
|
||||
.interactive(|_| Ok(Box::new(AttdefM::default()))),
|
||||
CommandSpec::new("attedit", "Single...", run_attedit)
|
||||
.menu(&["Modify", "Object", "Attribute", "Single..."])
|
||||
.alias(&["ate", "eattedit"])
|
||||
.params("{handle, values: {TAG: value}}"),
|
||||
attedit::attedit_spec(),
|
||||
attedit::dash_attedit_spec(),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"),
|
||||
|
|
@ -164,7 +164,7 @@ pub(crate) fn insert_with(
|
|||
if let EntityKind::Text(tt) = k {
|
||||
t = tt;
|
||||
}
|
||||
t.value = values.get(&ad.tag).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ad.text.value.clone());
|
||||
t.value = attedit::value_for(values, &ad.tag).unwrap_or_else(|| ad.text.value.clone());
|
||||
ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() });
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if bool_or(p, "explode", false) {
|
||||
return s.execute("explode", &json!({ "handles": [h.hex()] }));
|
||||
}
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
Ok(attedit::with_unknown_tags(s, h, &vals, json!({ "handle": h.hex() })))
|
||||
}
|
||||
|
||||
fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind {
|
||||
|
|
@ -236,23 +236,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_attedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
|
||||
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let mut n = 0;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = vals.get(&a.tag).and_then(Value::as_str) {
|
||||
a.text.value = v.to_string();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let src = s.doc()?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,453 @@
|
|||
//! ATTEDIT / EATTEDIT: one block reference's attribute values, asked for one after another.
|
||||
//! -ATTEDIT: the attributes picked by block name, tag and value specifications, edited one at a
|
||||
//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::curves::KW;
|
||||
use crate::cmd::machines::{SelOutcome, SelectPhase};
|
||||
use crate::cmd::qselect::wildcard;
|
||||
use crate::cmd::{CommandSpec, bad, str_param, targets};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Longest attribute value a string replacement may produce (bytes).
|
||||
const MAX_VALUE: usize = 65_536;
|
||||
|
||||
pub(super) fn attedit_spec() -> CommandSpec {
|
||||
CommandSpec::new("attedit", "Single...", run)
|
||||
.menu(&["Modify", "Object", "Attribute", "Single..."])
|
||||
.alias(&["ate", "eattedit"])
|
||||
.params("{handle, values: {TAG: value} (tags in any case)} → {changed, unknownTags?}")
|
||||
.interactive(|_| Ok(Box::new(AtteditM::default())))
|
||||
}
|
||||
|
||||
pub(super) fn dash_attedit_spec() -> CommandSpec {
|
||||
CommandSpec::new("-attedit", "Global", run_global)
|
||||
.menu(&["Modify", "Object", "Attribute", "Global"])
|
||||
.alias(&["-ate", "atte"])
|
||||
.params("{find, replace?, block?, tag?, value? (wildcard specifications, default *), handles? (default: every block reference in the current space)} → {changed}")
|
||||
.interactive(|_| Ok(Box::new(DashAtteditM::default())))
|
||||
}
|
||||
|
||||
/// The value `values` gives for `tag`, matching the tag in any case. Numbers and booleans count
|
||||
/// as their text.
|
||||
pub(super) fn value_for(values: &Map<String, Value>, tag: &str) -> Option<String> {
|
||||
let v = values.get(tag).or_else(|| values.iter().find(|(k, _)| k.eq_ignore_ascii_case(tag)).map(|(_, v)| v))?;
|
||||
match v {
|
||||
Value::String(t) => Some(t.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` with `unknownTags`: the keys of `values` that block reference `h` has no attribute for
|
||||
/// (left out when there are none).
|
||||
pub(super) fn with_unknown_tags(s: &Session, h: Handle, values: &Map<String, Value>, mut r: Value) -> Value {
|
||||
let tags: Vec<&str> = match s.doc().ok().and_then(|d| d.entity(h)).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => i.attribs.iter().map(|a| a.tag.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let unknown: Vec<&String> = values.keys().filter(|k| !tags.iter().any(|t| t.eq_ignore_ascii_case(k))).collect();
|
||||
if !unknown.is_empty()
|
||||
&& let Some(o) = r.as_object_mut()
|
||||
{
|
||||
o.insert("unknownTags".into(), json!(unknown));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
|
||||
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let mut n = 0;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = value_for(&vals, &a.tag) {
|
||||
a.text.value = v;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(with_unknown_tags(s, h, &vals, json!({ "changed": n })))
|
||||
}
|
||||
|
||||
/// A block name, tag or value specification: comma-separated wildcard patterns, any case. For
|
||||
/// values, `\` matches an empty value.
|
||||
fn spec_matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| if p.trim() == "\\" { text.is_empty() } else { wildcard(p.trim(), text) })
|
||||
}
|
||||
|
||||
/// Specifications choosing attributes: block name, tag, value.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Specs {
|
||||
block: String,
|
||||
tag: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Default for Specs {
|
||||
fn default() -> Self {
|
||||
Specs { block: "*".into(), tag: "*".into(), value: "*".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The attributes of block references `hs` that match `specs`, as (block reference, attribute
|
||||
/// index). Invisible attributes count only with `invisible` (or while ATTMODE shows them).
|
||||
fn matching(s: &Session, hs: &[Handle], specs: &Specs, invisible: bool) -> Result<Vec<(Handle, usize)>> {
|
||||
let d = s.doc()?;
|
||||
let invisible = invisible || d.header.i64("ATTMODE", 1) == 2;
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
|
||||
if !spec_matches(&specs.block, &ins.block) {
|
||||
continue;
|
||||
}
|
||||
for (k, a) in ins.attribs.iter().enumerate() {
|
||||
if !a.constant && (invisible || !a.invisible) && spec_matches(&specs.tag, &a.tag) && spec_matches(&specs.value, &a.text.value) {
|
||||
out.push((*h, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every object in the current space.
|
||||
fn all_in_space(s: &Session) -> Result<Vec<Handle>> {
|
||||
let d = s.doc()?;
|
||||
Ok(d.space(&s.space()).map(|st| st.iter().map(|e| e.handle).collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Change attribute `k` of block reference `h`; returns whether its value changed.
|
||||
fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option<String>) -> Result<bool> {
|
||||
let mut changed = false;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(ins) = &mut e.kind
|
||||
&& let Some(a) = ins.attribs.get_mut(k)
|
||||
&& let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)
|
||||
{
|
||||
a.text.value = v;
|
||||
changed = true;
|
||||
}
|
||||
})?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Replace every `find` in `value` with `with`, unless the result would be too long.
|
||||
fn replaced(value: &str, find: &str, with: &str) -> Option<String> {
|
||||
if find.is_empty() || !value.contains(find) {
|
||||
return None;
|
||||
}
|
||||
let n = value.matches(find).count();
|
||||
let len = value.len().checked_sub(n.checked_mul(find.len())?)?.checked_add(n.checked_mul(with.len())?)?;
|
||||
(len <= MAX_VALUE).then(|| value.replace(find, with))
|
||||
}
|
||||
|
||||
/// Replace `find` with `with` in the given attributes; returns how many changed.
|
||||
fn replace_all(s: &mut Session, items: &[(Handle, usize)], find: &str, with: &str) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for it in items {
|
||||
if edit(s, *it, |v| replaced(v, find, with))? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn run_global(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad("-attedit", "`find` (a non-empty string) is required"))?;
|
||||
let with = str_param(p, "replace").unwrap_or("");
|
||||
let spec = |k| str_param(p, k).unwrap_or("*").to_string();
|
||||
let specs = Specs { block: spec("block"), tag: spec("tag"), value: spec("value") };
|
||||
let hs = if p.get("handles").is_some() || p.get("handle").is_some() { targets(s, p)? } else { all_in_space(s)? };
|
||||
let items = matching(s, &hs, &specs, true)?;
|
||||
let (find, with) = (find.to_string(), with.to_string());
|
||||
Ok(json!({ "changed": replace_all(s, &items, &find, &with)? }))
|
||||
}
|
||||
|
||||
// ---------------- ATTEDIT / EATTEDIT ----------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct AtteditM {
|
||||
block: Option<Handle>,
|
||||
/// Index, tag and current value of each editable attribute of the picked block reference.
|
||||
attribs: Vec<(usize, String, String)>,
|
||||
/// The values answered so far, in attribute order.
|
||||
values: Vec<String>,
|
||||
}
|
||||
|
||||
impl AtteditM {
|
||||
fn pick(&mut self, s: &Session, hs: &[Handle]) -> Result<()> {
|
||||
let d = s.doc()?;
|
||||
let mut msg = "Select a block reference.";
|
||||
for h in hs {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
|
||||
let atts: Vec<(usize, String, String)> =
|
||||
ins.attribs.iter().enumerate().filter(|(_, a)| !a.constant).map(|(k, a)| (k, a.tag.clone(), a.text.value.clone())).collect();
|
||||
if atts.is_empty() {
|
||||
msg = "That block reference has no editable attributes.";
|
||||
continue;
|
||||
}
|
||||
self.block = Some(*h);
|
||||
self.attribs = atts;
|
||||
return Ok(());
|
||||
}
|
||||
Err(EngineError::Other(msg.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AtteditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTEDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.attribs.get(self.values.len()) {
|
||||
Some((_, tag, cur)) if self.block.is_some() => Prompt::new(format!("Enter value for {tag}"), Accept::TEXT).default(cur.clone()),
|
||||
_ => Prompt::new("Select block reference", Accept::SELECT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(h) = self.block else {
|
||||
return match i {
|
||||
Input::Pick(hs) => self.pick(s, &hs).map(|_| Step::Continue),
|
||||
Input::Enter => Ok(Step::Done),
|
||||
Input::Text(_) | Input::Keyword(_) => Err(EngineError::Other("Select a block reference.".into())),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
};
|
||||
let cur = self.attribs.get(self.values.len()).map(|a| a.2.clone()).unwrap_or_default();
|
||||
self.values.push(match i {
|
||||
Input::Text(t) => t,
|
||||
Input::Enter => cur,
|
||||
_ => return Ok(Step::Continue),
|
||||
});
|
||||
if self.values.len() < self.attribs.len() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// All answered: apply them together (one undo step).
|
||||
for ((k, _, _), v) in self.attribs.iter().zip(&self.values) {
|
||||
edit(s, (h, *k), |_| Some(v.clone()))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- -ATTEDIT ----------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
OneAtATime,
|
||||
VisibleOnly,
|
||||
BlockSpec,
|
||||
TagSpec,
|
||||
ValueSpec,
|
||||
Select,
|
||||
/// One at a time: what to do with the current attribute.
|
||||
Option,
|
||||
ValueKind,
|
||||
NewValue,
|
||||
Find,
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DashAtteditM {
|
||||
stage: Stage,
|
||||
one_at_a_time: bool,
|
||||
visible_only: bool,
|
||||
specs: Specs,
|
||||
sel: SelectPhase,
|
||||
items: Vec<(Handle, usize)>,
|
||||
/// The attribute being edited one at a time (index into `items`).
|
||||
cur: usize,
|
||||
find: String,
|
||||
}
|
||||
|
||||
fn invalid_keyword() -> EngineError {
|
||||
EngineError::Other("Invalid option keyword.".into())
|
||||
}
|
||||
|
||||
impl DashAtteditM {
|
||||
fn yes(i: &Input) -> Result<bool> {
|
||||
match i {
|
||||
Input::Keyword(k) => Ok(k == "Yes"),
|
||||
Input::Enter => Ok(true),
|
||||
_ => Err(invalid_keyword()),
|
||||
}
|
||||
}
|
||||
/// The attributes are chosen: start editing them.
|
||||
fn chosen(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
self.items = matching(s, hs, &self.specs, !self.visible_only && !self.one_at_a_time)?;
|
||||
s.set_selection(Vec::new());
|
||||
let n = self.items.len();
|
||||
s.echo(format!("{n} attribute{} selected.", if n == 1 { "" } else { "s" }));
|
||||
if n == 0 {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
if self.one_at_a_time {
|
||||
self.cur = 0;
|
||||
self.show_current(s);
|
||||
self.stage = Stage::Option;
|
||||
} else {
|
||||
self.stage = Stage::Find;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn show_current(&self, s: &mut Session) {
|
||||
let Some((h, k)) = self.items.get(self.cur).copied() else { return };
|
||||
if let Ok(d) = s.doc()
|
||||
&& let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind)
|
||||
&& let Some(a) = ins.attribs.get(k)
|
||||
{
|
||||
let line = format!("{} = {}", a.tag, a.text.value);
|
||||
s.echo(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for DashAtteditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"-ATTEDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::OneAtATime => Prompt::new("Edit attributes one at a time?", KW).kw(&["Yes", "No"]).default("Y"),
|
||||
Stage::VisibleOnly => Prompt::new("Edit only attributes visible on screen?", KW).kw(&["Yes", "No"]).default("Y"),
|
||||
Stage::BlockSpec => Prompt::new("Enter block name specification", Accept::TEXT).default("*"),
|
||||
Stage::TagSpec => Prompt::new("Enter attribute tag specification", Accept::TEXT).default("*"),
|
||||
Stage::ValueSpec => Prompt::new("Enter attribute value specification", Accept::TEXT).default("*"),
|
||||
Stage::Select => Prompt::new("Select attributes", Accept::SELECT),
|
||||
Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Next"]).default("N"),
|
||||
Stage::ValueKind => Prompt::new("Enter type of value modification", KW).kw(&["Change", "Replace"]).default("R"),
|
||||
Stage::NewValue => Prompt::new("Enter new attribute value", Accept::TEXT),
|
||||
Stage::Find => Prompt::new("Enter string to change", Accept::TEXT),
|
||||
Stage::Replace => Prompt::new("Enter new string", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
// Free-text answers; Enter gives "" (the specifications then take their default "*").
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.clone()),
|
||||
Input::Enter => Some(String::new()),
|
||||
_ => None,
|
||||
};
|
||||
match self.stage {
|
||||
Stage::OneAtATime => {
|
||||
self.one_at_a_time = Self::yes(&i)?;
|
||||
self.stage = if self.one_at_a_time {
|
||||
Stage::BlockSpec
|
||||
} else {
|
||||
s.echo("Performing global editing of attribute values.");
|
||||
Stage::VisibleOnly
|
||||
};
|
||||
}
|
||||
Stage::VisibleOnly => {
|
||||
self.visible_only = Self::yes(&i)?;
|
||||
self.stage = Stage::BlockSpec;
|
||||
}
|
||||
Stage::BlockSpec | Stage::TagSpec | Stage::ValueSpec => {
|
||||
let Some(t) = text else { return Ok(Step::Continue) };
|
||||
let t = if t.trim().is_empty() { "*".to_string() } else { t.trim().to_string() };
|
||||
match self.stage {
|
||||
Stage::BlockSpec => {
|
||||
self.specs.block = t;
|
||||
self.stage = Stage::TagSpec;
|
||||
}
|
||||
Stage::TagSpec => {
|
||||
self.specs.tag = t;
|
||||
self.stage = Stage::ValueSpec;
|
||||
}
|
||||
_ => {
|
||||
self.specs.value = t;
|
||||
if !self.one_at_a_time && !self.visible_only {
|
||||
let hs = all_in_space(s)?;
|
||||
return self.chosen(s, &hs);
|
||||
}
|
||||
self.stage = Stage::Select;
|
||||
}
|
||||
}
|
||||
}
|
||||
Stage::Select => {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => self.chosen(s, &[]),
|
||||
SelOutcome::Done(hs) => self.chosen(s, &hs),
|
||||
};
|
||||
}
|
||||
Stage::Option => match &i {
|
||||
Input::Keyword(k) if k == "Value" => self.stage = Stage::ValueKind,
|
||||
Input::Keyword(_) | Input::Enter => {
|
||||
self.cur += 1;
|
||||
if self.cur >= self.items.len() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.show_current(s);
|
||||
}
|
||||
Input::Text(t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let later = ["Position", "Height", "Angle", "Style", "Layer", "Color"];
|
||||
return match later.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) {
|
||||
Some(k) => Err(EngineError::Other(format!("{k} is not available yet."))),
|
||||
None => Err(invalid_keyword()),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Stage::ValueKind => match &i {
|
||||
Input::Keyword(k) if k == "Change" => self.stage = Stage::Find,
|
||||
Input::Keyword(_) | Input::Enter => self.stage = Stage::NewValue,
|
||||
_ => return Err(invalid_keyword()),
|
||||
},
|
||||
Stage::NewValue => {
|
||||
let Some(v) = text else { return Ok(Step::Continue) };
|
||||
if let Some(it) = self.items.get(self.cur).copied() {
|
||||
edit(s, it, |_| Some(v))?;
|
||||
}
|
||||
self.show_current(s);
|
||||
self.stage = Stage::Option;
|
||||
}
|
||||
Stage::Find => match text {
|
||||
Some(t) if !t.is_empty() => {
|
||||
self.find = t;
|
||||
self.stage = Stage::Replace;
|
||||
}
|
||||
Some(_) => return Err(EngineError::Other("Requires the string to change.".into())),
|
||||
None => {}
|
||||
},
|
||||
Stage::Replace => {
|
||||
let Some(with) = text else { return Ok(Step::Continue) };
|
||||
let find = std::mem::take(&mut self.find);
|
||||
if self.one_at_a_time {
|
||||
let items: Vec<(Handle, usize)> = self.items.get(self.cur).copied().into_iter().collect();
|
||||
replace_all(s, &items, &find, &with)?;
|
||||
self.show_current(s);
|
||||
self.stage = Stage::Option;
|
||||
} else {
|
||||
let n = replace_all(s, &self.items, &find, &with)?;
|
||||
s.echo(format!("{n} attribute{} changed.", if n == 1 { "" } else { "s" }));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacement_is_bounded_and_specs_take_lists() {
|
||||
assert_eq!(replaced("a-b-c", "-", "+").as_deref(), Some("a+b+c"));
|
||||
assert_eq!(replaced("abc", "x", "y"), None);
|
||||
assert_eq!(replaced("aaaa", "a", &"x".repeat(MAX_VALUE)), None);
|
||||
assert!(spec_matches("door,WIN*", "window"));
|
||||
assert!(spec_matches("\\", ""));
|
||||
assert!(!spec_matches("\\", "x"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
//! ATTEDIT / -ATTEDIT typed at the command line, and attribute tags matched in any case.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Block B: a line from 0,0 to 10,0 with attributes T (default "one") and U (default "u").
|
||||
fn with_block() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("attdef", &json!({"tag": "T", "default": "one", "at": [0, 1]})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "U", "default": "u", "at": [0, 2]})).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();
|
||||
s
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, x: f64, attribs: Value) -> Value {
|
||||
s.execute("insert", &json!({"name": "B", "at": [x, 0], "attribs": attribs})).unwrap()
|
||||
}
|
||||
|
||||
fn handle(r: &Value) -> Handle {
|
||||
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn value(s: &Session, h: Handle, tag: &str) -> String {
|
||||
let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };
|
||||
i.attribs.iter().find(|a| a.tag == tag).unwrap().text.value.clone()
|
||||
}
|
||||
|
||||
fn type_lines(s: &mut Session, lines: &[&str]) {
|
||||
for l in lines {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attedit_asks_for_a_block_then_each_value() {
|
||||
let mut s = with_block();
|
||||
let h = handle(&insert(&mut s, 100.0, json!({})));
|
||||
s.cmdline("attedit").unwrap();
|
||||
assert!(s.prompt_text().contains("Select block reference"), "{}", s.prompt_text());
|
||||
s.cmdline("105,0").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter value for T <one>"), "{}", s.prompt_text());
|
||||
// A value with spaces; Enter keeps U as it was.
|
||||
type_lines(&mut s, &["new value", ""]);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(value(&s, h, "T"), "new value");
|
||||
assert_eq!(value(&s, h, "U"), "u");
|
||||
// One undo step.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(value(&s, h, "T"), "one");
|
||||
|
||||
// EATTEDIT is the same command; a pick on nothing editable re-prompts.
|
||||
s.execute("line", &json!({"points": [[0, 50], [10, 50]]})).unwrap();
|
||||
type_lines(&mut s, &["eattedit", "5,50"]);
|
||||
assert!(s.log.iter().any(|l| l.contains("Select a block reference.")), "{:?}", s.log);
|
||||
type_lines(&mut s, &["105,0", "x", "y"]);
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("x".into(), "y".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_attedit_global_replaces_a_string_in_matching_attributes() {
|
||||
let mut s = with_block();
|
||||
let a = handle(&insert(&mut s, 100.0, json!({"T": "A-1", "U": "A-9"})));
|
||||
let b = handle(&insert(&mut s, 200.0, json!({"T": "A-2"})));
|
||||
// Not one at a time, not only visible ones: every reference of B, tag T, any value.
|
||||
s.script("-attedit\nn\nn\nb\nt\n\nA-\nX-\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("X-1".into(), "X-2".into()));
|
||||
assert_eq!(value(&s, a, "U"), "A-9", "tag specification filters");
|
||||
assert!(s.log.iter().any(|l| l == "2 attributes changed."), "{:?}", s.log);
|
||||
|
||||
// The JSON form does the same.
|
||||
let r = s.execute("-attedit", &json!({"tag": "u", "find": "9", "replace": "8"})).unwrap();
|
||||
assert_eq!(r["changed"], 1);
|
||||
assert_eq!(value(&s, a, "U"), "A-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_attedit_one_at_a_time_edits_the_selected_attributes() {
|
||||
let mut s = with_block();
|
||||
let h = handle(&insert(&mut s, 100.0, json!({"T": "abc"})));
|
||||
type_lines(&mut s, &["-attedit", "y", "", "T", "", "105,0", ""]);
|
||||
assert!(s.prompt_text().contains("[Value/Next] <N>"), "{}", s.prompt_text());
|
||||
// Unsupported options say so and keep the prompt.
|
||||
s.cmdline("p").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Position is not available yet")), "{:?}", s.log);
|
||||
type_lines(&mut s, &["v", "c", "b", "B", "v", "r", "replaced value", "n"]);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(value(&s, h, "T"), "replaced value");
|
||||
assert_eq!(value(&s, h, "U"), "u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_tags_match_in_any_case_and_unknown_tags_are_reported() {
|
||||
let mut s = with_block();
|
||||
let r = insert(&mut s, 100.0, json!({"t": "two", "nope": "x"}));
|
||||
let h = handle(&r);
|
||||
assert_eq!(value(&s, h, "T"), "two");
|
||||
assert_eq!(r["unknownTags"], json!(["nope"]));
|
||||
|
||||
let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"t": "three", "u": 4, "W": "w"}})).unwrap();
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(r["unknownTags"], json!(["W"]));
|
||||
assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("three".into(), "4".into()));
|
||||
|
||||
let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "four"}})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 1}));
|
||||
}
|
||||
|
|
@ -289,6 +289,7 @@
|
|||
Geometry Геометрія
|
||||
Get Variable Отримати змінну
|
||||
Get View Отримати вид
|
||||
Global Глобально
|
||||
Global width Загальна ширина
|
||||
Grab bag Список
|
||||
Gradient Градієнт
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 7.
|
Loading…
Reference in New Issue