feat(variables): indexed multi-fill model ops + selected-node mutators

Foundation for a per-fill Fill editor (stack multiple fills, add/remove by
row). Additive, backward-compatible (fill is already Vec<PenFill>; old .op
files load as a single fill):
- fills.rs: fill_type_of, fill_count, fill_type_at, add_fill,
  remove_fill(i), set_fill_type_at(i), set_fill_hex_at(i),
  set_fill_opacity_at(i).
- property_edit_mutators.rs: add_selected_fill, remove_selected_fill(i),
  set_selected_fill_type_at(i), set_selected_fill_hex_at(i),
  set_selected_fill_opacity_at(i) (via a with_selected_node helper).

UI/host wiring lands separately.
This commit is contained in:
Kayshen-X 2026-06-21 09:01:21 +08:00
parent f2f50e36cf
commit 881d9356d3
2 changed files with 147 additions and 6 deletions

View file

@ -590,15 +590,23 @@ pub fn set_primary_fill_hex(node: &mut PenNode, hex: &str) -> bool {
/// model has no scalar `fill_type` field — the kind is the variant of
/// the first `PenFill`. A node with no fills reports `Solid` (the
/// neutral default the property panel paints).
pub fn first_fill_type(node: &PenNode) -> FillType {
match node_fills(node).and_then(|f| f.first()) {
Some(PenFill::Solid(_)) | None => FillType::Solid,
Some(PenFill::LinearGradient(_)) => FillType::LinearGradient,
Some(PenFill::RadialGradient(_)) => FillType::RadialGradient,
Some(PenFill::Image(_)) => FillType::Image,
/// `FillType` of a single `PenFill` (the kind is the variant).
pub fn fill_type_of(fill: &PenFill) -> FillType {
match fill {
PenFill::Solid(_) => FillType::Solid,
PenFill::LinearGradient(_) => FillType::LinearGradient,
PenFill::RadialGradient(_) => FillType::RadialGradient,
PenFill::Image(_) => FillType::Image,
}
}
pub fn first_fill_type(node: &PenNode) -> FillType {
node_fills(node)
.and_then(|f| f.first())
.map(fill_type_of)
.unwrap_or(FillType::Solid)
}
/// Build a default `PenFill` of the given `FillType`, seeding it with
/// `hex` where the variant carries a single colour (Solid) or a stop
/// list (gradients). `Image` has no colour, so it gets an empty `url`.
@ -748,6 +756,100 @@ pub fn clear_primary_fills(node: &mut PenNode) -> bool {
true
}
// ── Multi-fill (indexed) ops ───────────────────────────────────────
//
// The property panel's Fill section stacks one editable row per `PenFill`
// (header "+" appends, each row's "×" removes). These mirror the
// `set_primary_*` ops but address a fill by index so every row edits its
// own fill. `false` when the node carries no `fill` field or the index is
// out of range.
/// Number of fills on the node (0 for variants without a `fill` field).
pub fn fill_count(node: &PenNode) -> usize {
node_fills(node).map(|f| f.len()).unwrap_or(0)
}
/// `FillType` of the fill at `index`, if present.
pub fn fill_type_at(node: &PenNode, index: usize) -> Option<FillType> {
node_fills(node).and_then(|fills| fills.get(index)).map(fill_type_of)
}
/// Append a new default solid fill (the TS new-fill default `#d1d5db`).
pub fn add_fill(node: &mut PenNode) -> bool {
let Some(fills) = node_fills_mut(node) else {
return false;
};
fills.push(default_fill_of_type(FillType::Solid, "#d1d5db"));
true
}
/// Remove the fill at `index`.
pub fn remove_fill(node: &mut PenNode, index: usize) -> bool {
let Some(fills) = node_fills_mut(node) else {
return false;
};
if index >= fills.len() {
return false;
}
fills.remove(index);
true
}
/// Convert the fill at `index` to `kind`, preserving as much of the body
/// as the target variant allows (mirrors [`set_primary_fill_type`]).
pub fn set_fill_type_at(node: &mut PenNode, index: usize, kind: FillType) -> bool {
let Some(fills) = node_fills_mut(node) else {
return false;
};
let Some(existing) = (index < fills.len()).then(|| fills.remove(index)) else {
return false;
};
fills.insert(index, convert_fill(existing, kind));
true
}
/// Write `hex` into the solid fill at `index`. No-op (returns `false`) if
/// that fill isn't a solid — type changes go through [`set_fill_type_at`].
pub fn set_fill_hex_at(node: &mut PenNode, index: usize, hex: &str) -> bool {
let Some(fills) = node_fills_mut(node) else {
return false;
};
match fills.get_mut(index) {
Some(PenFill::Solid(body)) => {
body.color = hex.to_string();
true
}
_ => false,
}
}
/// Set the opacity (0.0..=1.0) of the fill at `index`.
pub fn set_fill_opacity_at(node: &mut PenNode, index: usize, opacity: f32) -> bool {
let Some(fills) = node_fills_mut(node) else {
return false;
};
let opacity = opacity.clamp(0.0, 1.0);
match fills.get_mut(index) {
Some(PenFill::Solid(b)) => {
b.opacity = Some(opacity);
true
}
Some(PenFill::LinearGradient(b)) => {
b.opacity = Some(opacity);
true
}
Some(PenFill::RadialGradient(b)) => {
b.opacity = Some(opacity);
true
}
Some(PenFill::Image(b)) => {
b.opacity = Some(opacity);
true
}
_ => false,
}
}
/// Stroke parallel to [`set_primary_fill_hex`]. Creates a default
/// 1-px stroke when the node has none, so a colour write always
/// lands a visible stroke. `false` for variants without a stroke.

View file

@ -353,6 +353,45 @@ impl EditorState {
crate::fills::clear_primary_fills(node)
}
// ── Multi-fill (indexed) selected-node mutators ────────────────
// The Fill section stacks one row per `PenFill`; these address a fill
// by index so the header "+" appends and each row's "×" / type / colour
// / opacity edits its own fill.
/// Run `f` on the selected, editable node, returning `false` when there
/// is no real editable selection.
fn with_selected_node<R>(&mut self, f: impl FnOnce(&mut PenNode) -> R) -> Option<R> {
let sel = self.selection.anchor.clone();
if !sel.is_real() || !self.is_editable(&sel) {
return None;
}
find_node_mut(self.active_children_mut(), &sel).map(f)
}
pub fn add_selected_fill(&mut self) -> bool {
self.with_selected_node(crate::fills::add_fill).unwrap_or(false)
}
pub fn remove_selected_fill(&mut self, index: usize) -> bool {
self.with_selected_node(|node| crate::fills::remove_fill(node, index))
.unwrap_or(false)
}
pub fn set_selected_fill_type_at(&mut self, index: usize, fill_type: crate::FillType) -> bool {
self.with_selected_node(|node| crate::fills::set_fill_type_at(node, index, fill_type))
.unwrap_or(false)
}
pub fn set_selected_fill_hex_at(&mut self, index: usize, hex: &str) -> bool {
self.with_selected_node(|node| crate::fills::set_fill_hex_at(node, index, hex))
.unwrap_or(false)
}
pub fn set_selected_fill_opacity_at(&mut self, index: usize, opacity: f32) -> bool {
self.with_selected_node(|node| crate::fills::set_fill_opacity_at(node, index, opacity))
.unwrap_or(false)
}
pub fn set_selected_image_fill_mode(&mut self, mode: crate::ImageFillMode) -> bool {
let sel = self.selection.anchor.clone();
if !sel.is_real() || !self.is_editable(&sel) {