mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
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.
148 lines
6.0 KiB
Rust
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();
|
|
}
|
|
}
|