Files
figcraft/crates/engine/src/rename.rs
T
bflatastic 626a9eac08 M1: layers and assets panels behaviour suite and fixes
A behaviour suite for the layers panel and the Assets panel written from the public documentation
(table rows with one-line derivations; rows the documentation is silent on say so and pin today's
behaviour), and the fixes it found.

- Engine suite `crates/engine/src/panels_behaviour_tests.rs` (6 tests, about 50 rows): reordering
  and moving layers into frames, groups and auto layout, refused drops, renaming several layers,
  show/hide and lock rules, pages and dividers, Assets folders, hidden-from-publishing names, drops.
- Panel suite `crates/ui-egui/src/layers_behaviour_tests.rs` (5 tests, driven through egui
  headless): click / Shift range / Cmd-Ctrl toggle, Alt-click expand, Collapse layers, sweeping
  across eyes and padlocks, the Rename layers dialog, dragging pages, dividers.

Fixes:
- Several layers dropped together in the layers panel land together at the drop point (layers
  from apart split around it before).
- Collapse layers (Alt+L) keeps the rows leading to the selection open (it closed everything).
- The eye and padlock no longer vanish when the pointer reaches them (the row lost its hover to
  them, so clicks could miss); dragging across eyes or padlocks sets every row passed in one undo
  step. Layers inside a locked frame show a faint padlock.
- Several layers selected + Cmd/Ctrl+R / F2 / Rename open a Rename layers dialog (Match, Rename to,
  Current name / Number up / Number down buttons, start value, preview) instead of renaming the top
  row only.
- `object.batchRename`: `$n` counts up and `$N` down (they were swapped), `$nnn` pads, `start`
  sets where numbers start/stop, `regex: true` matches a regular expression with `$1`..`$9`,
  `` $` `` and `$'`, and codes work in the replacement too. Returns the new names.
- Pages: dragged up and down the list to reorder; a blank page named with a leading hyphen is a
  divider (drawn as a line, never opened, holds no layers); duplicates are named "Copy of ...";
  the last page that isn't a divider can't be deleted. `page.list` reports `divider`.
- Assets panel: components filed in folders by page, the frames holding them and the slash parts
  of their names (folders fold away; open while searching), tiles show the last name part;
  `component.list` gives each component's `folder` and `label` for agents.
- Publishing: a leading `.` or `_` keeps a component (style, collection) out of a library even
  after it was published, and removing the prefix brings it back (the previous publish's choice
  won before).

The engine gains the `regex` crate (already in the lock file through env_logger) for the
documented regular-expression matching.
2026-10-12 03:47:34 +07:00

148 lines
6.0 KiB
Rust

//! Renaming several layers at once (`object.batchRename`, the Rename layers dialog): a match
//! (plain text, or a regular expression) whose occurrences are replaced, and/or a pattern for the
//! whole name. Both understand the codes: `$&` the current name (the matched text when matching),
//! `` $` `` / `$'` the text before / after the match, `$1`…`$9` a regular expression's groups,
//! `$n` a number counting up and `$N` one counting down (`$nnn` pads it to three digits), `$$` a
//! dollar sign.
use figcraft_doc::{Document, NodeId};
/// Longest match pattern accepted (characters).
const MAX_FIND: usize = 1_000;
/// Longest name a rename produces (characters).
const MAX_NAME: usize = 10_000;
/// Widest zero padding of a number code.
const MAX_PAD: usize = 12;
enum Find {
Text(String),
Regex(regex::Regex),
}
/// One batch rename's settings.
pub struct BatchRename {
find: Option<Find>,
replace: String,
pattern: Option<String>,
/// Where `$n` starts and `$N` stops.
start: u64,
}
/// What the codes of one replacement stand for.
struct Ctx<'a> {
matched: &'a str,
before: &'a str,
after: &'a str,
groups: Option<&'a regex::Captures<'a>>,
up: u64,
down: u64,
}
impl BatchRename {
/// `find` (empty: none) as text or, with `regex`, a regular expression, replaced by `replace`;
/// then `pattern` for the whole name; numbers from `start` (default 1).
pub fn new(find: Option<&str>, regex: bool, replace: &str, pattern: Option<&str>, start: Option<f64>) -> Result<Self, String> {
let find = match find.filter(|f| !f.is_empty()) {
None => None,
Some(f) if f.chars().count() > MAX_FIND => return Err(format!("the match is longer than {MAX_FIND} characters")),
Some(f) if regex => {
Some(Find::Regex(regex::RegexBuilder::new(f).size_limit(1 << 20).build().map_err(|e| format!("invalid regular expression: {e}"))?))
}
Some(f) => Some(Find::Text(f.to_string())),
};
let start = start.filter(|s| s.is_finite()).map_or(1, |s| s.clamp(0.0, 1e12).round() as u64);
Ok(Self { find, replace: replace.to_string(), pattern: pattern.map(str::to_string), start })
}
/// The new name of layer `k` of `n` (stacking order, bottom first), now `name`.
pub fn apply(&self, name: &str, k: usize, n: usize) -> String {
let up = self.start.saturating_add(k as u64);
let down = self.start.saturating_add(n.saturating_sub(k + 1) as u64);
let mut out = match &self.find {
None => name.to_string(),
Some(Find::Text(f)) => {
let mut out = String::new();
let mut last = 0;
for (i, _) in name.match_indices(f.as_str()) {
let end = i + f.len();
out.push_str(name.get(last..i).unwrap_or_default());
let (before, after) = (name.get(..i).unwrap_or_default(), name.get(end..).unwrap_or_default());
expand(&self.replace, &Ctx { matched: f, before, after, groups: None, up, down }, &mut out);
last = end;
}
out.push_str(name.get(last..).unwrap_or_default());
out
}
Some(Find::Regex(re)) => {
let mut out = String::new();
let mut last = 0;
for caps in re.captures_iter(name) {
let Some(m) = caps.get(0) else { continue };
out.push_str(name.get(last..m.start()).unwrap_or_default());
let (before, after) = (name.get(..m.start()).unwrap_or_default(), name.get(m.end()..).unwrap_or_default());
expand(&self.replace, &Ctx { matched: m.as_str(), before, after, groups: Some(&caps), up, down }, &mut out);
last = m.end();
}
out.push_str(name.get(last..).unwrap_or_default());
out
}
};
if let Some(p) = &self.pattern {
let mut whole = String::new();
expand(p, &Ctx { matched: &out, before: "", after: "", groups: None, up, down }, &mut whole);
out = whole;
}
match out.char_indices().nth(MAX_NAME) {
Some((cut, _)) => out.get(..cut).unwrap_or_default().to_string(),
None => out,
}
}
/// The new names of `ids` (taken in stacking order, bottom first).
pub fn plan(&self, doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, String)> {
let ids = crate::cmd::edit::z_sorted(doc, ids);
let n = ids.len();
ids.iter().enumerate().filter_map(|(k, i)| doc.node(*i).map(|node| (*i, self.apply(&node.name, k, n)))).collect()
}
}
/// Append `template` with its codes filled in from `c`.
fn expand(template: &str, c: &Ctx, out: &mut String) {
let mut it = template.chars().peekable();
while let Some(ch) = it.next() {
if ch != '$' {
out.push(ch);
continue;
}
match it.peek().copied() {
Some('&') => out.push_str(c.matched),
Some('`') => out.push_str(c.before),
Some('\'') => out.push_str(c.after),
Some('$') => out.push('$'),
Some(d @ ('n' | 'N')) => {
let mut width = 0;
while it.peek() == Some(&d) {
it.next();
width += 1;
}
let v = if d == 'n' { c.up } else { c.down };
out.push_str(&format!("{v:0w$}", w = width.min(MAX_PAD)));
continue;
}
Some(d @ '1'..='9') => match c.groups.filter(|g| (d as usize - '0' as usize) < g.len()) {
Some(g) => out.push_str(g.get(d as usize - '0' as usize).map_or("", |m| m.as_str())),
// Not a group of this match: the code stays as typed.
None => {
out.push('$');
out.push(d);
}
},
_ => {
out.push('$');
continue;
}
}
it.next();
}
}