Merge pull request #1857 from TrevorBrown-dev/fix/text-mixed-fill-section

M1: the Fill section shows a text's characters' fills, mixed when they differ, and a layer fill edit reaches every character
This commit is contained in:
BFlat
2026-11-07 11:36:20 +08:00
committed by GitHub
12 changed files with 344 additions and 15 deletions
+17 -1
View File
@@ -267,6 +267,18 @@ pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) ->
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
// A removal that names no paint among the rows shown changes nothing, the characters' own
// fills included; characters coloured differently show no rows at all ("Click + to
// replace mixed content").
if !add {
let shown = if fills { crate::cmd::lists::character_fills(n).map_or(0, |f| f.len()) } else { n.strokes.len() };
if index.unwrap_or(shown.saturating_sub(1)) >= shown {
continue;
}
}
if fills {
crate::cmd::lists::take_character_fills(n);
}
let frameish = n.kind.is_frame_like();
let list = if fills { &mut n.fills } else { &mut n.strokes };
if add {
@@ -298,6 +310,9 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
if fills {
crate::cmd::lists::take_character_fills(n);
}
let list = if fills { &mut n.fills } else { &mut n.strokes };
let at = idx.unwrap_or(list.len().saturating_sub(1));
if list.is_empty() {
@@ -344,7 +359,8 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
let (paint, stroke) = {
let doc = s.doc()?;
let n = doc.get(id)?;
let list = if fills { &n.fills } else { &n.strokes };
// A text's fills are what its characters show (the list the Design panel's rows are).
let list = if fills { crate::cmd::lists::character_fills(n).unwrap_or_default() } else { n.strokes.clone() };
let at = at.unwrap_or(list.len().saturating_sub(1));
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
(paint, (!fills).then(|| n.stroke.clone()))
+10 -3
View File
@@ -184,9 +184,10 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"image": image, "width": width, "height": height, "changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
/// The index of a layer's top visible image fill among the fills it shows (fills paint bottom to
/// top): a text's characters' shared list, so none while they are coloured differently.
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
crate::cmd::lists::character_fills(n)?.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
@@ -197,11 +198,15 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
let doc = s.doc()?;
let n = doc.get(id)?;
// A text's fills are its characters' (the Design panel's rows): the crop edits that list, and
// characters coloured differently have no row to crop ("Click + to replace mixed content").
let shown =
crate::cmd::lists::character_fills(n).ok_or_else(|| refuse("the characters have different fills, so there is no one fill to crop"))?;
let index = match get(p, "index") {
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
Some(_) => req_index(cmd, p, "index")?,
};
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let mut paint = shown.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
// An image already in Crop mode only enters crop mode: no edit, no undo step.
if img.scale_mode != ImageScaleMode::Crop {
@@ -209,6 +214,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
img.scale_mode = ImageScaleMode::Crop;
let n = s.doc_mut()?.get_mut(id)?;
// The characters' shared list becomes the layer's, so the crop shows on every one of them.
crate::cmd::lists::take_character_fills(n);
if let Some(f) = n.fills.get_mut(index) {
*f = paint;
}
+56 -2
View File
@@ -6,7 +6,7 @@
//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
//! the Design panel shows them in reverse, the topmost row first.
use figcraft_doc::{Document, LayoutGrid, Node, NodeId};
use figcraft_doc::{Document, LayoutGrid, Node, NodeId, NodeKind};
use serde_json::Value;
use super::{CommandSpec, ok};
@@ -189,6 +189,52 @@ fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, fi
}
}
/// The fills a text's characters show when every character shows the same list: the layer's
/// fills, or the one list that styling all of them while editing left on them. None when the
/// characters differ, as the layer's fill is then mixed ("Text nodes can have multiple sets of
/// fills if some characters are colored differently than others", the developer documentation's
/// `fills`). Any other layer: its fills.
pub fn character_fills(n: &Node) -> Option<Vec<figcraft_color::Paint>> {
let NodeKind::Text(t) = &n.kind else { return Some(n.fills.clone()) };
let mut spans = t.spans().into_iter().map(|(_, _, st)| st.fills.as_ref().unwrap_or(&n.fills));
let first = spans.next().unwrap_or(&n.fills).clone();
spans.all(|f| *f == first).then_some(first)
}
/// Whether any character of a text carries fills of its own (on its base style or a run).
pub(crate) fn has_character_fills(n: &Node) -> bool {
match &n.kind {
NodeKind::Text(t) => t.style.fills.is_some() || t.runs.iter().any(|r| r.style.fills.is_some()),
_ => false,
}
}
/// Before a layer-level edit of a text's fills (the Fill section, `fill.*`, `node.set`): the
/// characters' own fills go, so the edit shows on every one of them. One list they all shared
/// becomes the layer's first, so the edit starts from what the characters showed; characters
/// that differed fall back to the layer's fills, which the edit then replaces ("Click + to
/// replace mixed content"). Not a text, or no character with fills of its own: nothing changes.
pub(crate) fn take_character_fills(n: &mut Node) {
if !has_character_fills(n) {
return;
}
if let Some(shared) = character_fills(n)
&& shared != n.fills
{
let old = std::mem::replace(&mut n.fills, shared);
for (i, p) in old.iter().enumerate() {
rebind_edited_paint(n, true, i, p);
}
}
if let NodeKind::Text(t) = &mut n.kind {
t.style.fills = None;
for r in &mut t.runs {
r.style.fills = None;
}
t.normalize();
}
}
/// Where item `k` lands when the item at `from` moves to `to`.
pub fn moved_index(k: usize, from: usize, to: usize) -> usize {
if k == from {
@@ -250,11 +296,19 @@ fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result<V
let from = req_index(cmd, p, "from")?;
let to = req_index(cmd, p, "to")?;
for id in req_targets(cmd, s, p)? {
let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0);
// A text's characters' shared fills are the list the Design panel shows and reorders.
let len = s
.doc()?
.node(id)
.map(|n| if list == BoundList::Fills { character_fills(n).map_or(n.fills.len(), |f| f.len()) } else { list.len(n) })
.unwrap_or(0);
if from >= len || to >= len || from == to {
continue;
}
let n = s.doc_mut()?.get_mut(id)?;
if list == BoundList::Fills {
take_character_fills(n);
}
let moved = match list {
BoundList::Fills => reorder(&mut n.fills, from, to),
BoundList::Strokes => reorder(&mut n.strokes, from, to),
+3
View File
@@ -413,6 +413,9 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
if fills {
super::lists::take_character_fills(n);
}
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
for (i, p) in old.iter().enumerate() {
super::lists::rebind_edited_paint(n, fills, i, p);
+85 -1
View File
@@ -1,10 +1,94 @@
//! Range fills on text: set on a range, cleared back to the layer's fill.
//! Range fills on text: set on a range, cleared back to the layer's fill; a layer-level fill edit
//! reaches every character.
use serde_json::json;
use crate::Session;
use crate::cmd::lists::character_fills;
use crate::params::id_of;
/// The fill each character of `t` shows, as hex.
fn fills_shown(s: &Session, t: figcraft_doc::NodeId) -> Vec<String> {
let n = s.doc().unwrap().node(t).unwrap().clone();
let tx = n.kind.text().unwrap().clone();
(0..tx.char_count())
.map(|i| {
tx.style_at(i)
.fills
.clone()
.unwrap_or_else(|| n.fills.clone())
.iter()
.map(|p| p.solid_color().unwrap().to_hex())
.collect::<Vec<_>>()
.join("+")
})
.collect()
}
/// A text styled on a range, or on all of its characters, keeps those fills until the layer's
/// fill is edited: then every character takes the layer's fills, in one undo step (#1856).
#[test]
fn a_layer_fill_edit_reaches_every_character() {
let mut s = Session::new();
let t = id_of(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [t], "color": "#FF0000"})).unwrap();
let node = |s: &Session| s.doc().unwrap().node(t).unwrap().clone();
assert_eq!(character_fills(&node(&s)).map(|f| f.len()), Some(1), "one shared list");
// "ello" yellow: the characters differ, the layer's fill is mixed, the canvas keeps both.
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"]);
assert_eq!(character_fills(&node(&s)), None, "mixed");
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "reselecting changes nothing");
// The layer's fill set: every character takes it, their own fills are gone.
let steps = s.ds().unwrap().history.undo.len();
s.execute("fill.set", &json!({"ids": [t], "color": "#0000FF"})).unwrap();
assert_eq!(fills_shown(&s, t), ["0000FF"; 5]);
assert!(node(&s).kind.text().unwrap().runs.is_empty(), "no character keeps fills of its own");
assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1, "one undo step");
s.undo().unwrap();
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "undo puts the characters' fills back");
s.redo().unwrap();
// All of the characters styled while editing: they share a list the layer doesn't have yet.
// A fill added at the layer goes on top of that list, not the layer's old one.
s.execute("text.style", &json!({"id": t, "start": 0, "end": 5, "textFills": ["#00FF00"]})).unwrap();
assert_eq!(fills_shown(&s, t), ["00FF00"; 5]);
assert_eq!(character_fills(&node(&s)).map(|f| f.len()), Some(1), "one shared list, the characters'");
assert_eq!(node(&s).fills[0].solid_color().unwrap().to_hex(), "0000FF", "the layer's own list is older");
s.execute("fill.add", &json!({"ids": [t], "color": "#FF00FF"})).unwrap();
assert_eq!(fills_shown(&s, t), ["00FF00+FF00FF"; 5]);
assert!(node(&s).kind.text().unwrap().runs.is_empty());
assert_eq!(node(&s).fills.len(), 2, "the layer's list is the characters' now");
// `node.set` with a fill, and a mixed text again: both reach every character.
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"fill": "#123456"}})).unwrap();
assert_eq!(fills_shown(&s, t), ["123456"; 5]);
// A removal that names no paint leaves the characters' own fills alone; a copy takes the fill
// the characters show, the list the Design panel's rows are.
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
s.execute("fill.remove", &json!({"ids": [t], "index": 7})).unwrap();
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "nothing removed, nothing taken");
// Characters coloured differently show no fill rows at all: a removal with or without an
// index leaves the layer's hidden fill and the characters' own alone, and there is no row to
// crop (the command is unavailable, as the menu has it).
s.execute("fill.remove", &json!({"ids": [t], "index": 0})).unwrap();
s.execute("fill.remove", &json!({"ids": [t]})).unwrap();
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "mixed: nothing to remove");
assert_eq!(node(&s).fills.len(), 1, "the layer's own fill stays");
assert!(matches!(s.execute("image.crop", &json!({"ids": [t]})), Err(crate::EngineError::Disabled(..))), "mixed: nothing to crop");
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "and nothing taken");
assert_eq!(crate::cmd::image::top_image_fill(&node(&s)), None, "the menu finds no image to crop either");
s.execute("text.style", &json!({"id": t, "start": 0, "end": 5, "textFills": ["#00FF00"]})).unwrap();
s.execute("fill.copy", &json!({"id": t})).unwrap();
let copied = s.clipboard.paint.clone().and_then(|(_, p)| p.solid_color()).map(|c| c.to_hex());
assert_eq!(copied.as_deref(), Some("00FF00"), "the characters' fill is copied, not the layer's older one");
// A rectangle is left alone by all of this.
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [r], "color": "#ABCDEF"})).unwrap();
assert_eq!(character_fills(s.doc().unwrap().node(r).unwrap()).map(|f| f.len()), Some(1));
}
#[test]
fn range_fill_sets_and_clears() {
let mut s = Session::new();
+17 -5
View File
@@ -40,6 +40,15 @@ enum Drag {
/// The Crop-mode image being edited: (node, fills, index, paint, image rect in the unit box).
type UnitRect = (f64, f64, f64, f64);
/// The paint list a picker paint at an index belongs to on layer `n`, as the Fill section and the
/// colour picker list it: for fills, a text's characters' shared fills (`character_fills`: the
/// layer's own when the characters have none), else the layer's strokes. None when a text's
/// characters have different fills. Writes go through `fill.set`, which moves the characters'
/// list to the layer first, so the handles read and write the same paint (#1856).
fn edited_list(n: &figcraft_doc::Node, fills: bool) -> Option<Vec<Paint>> {
if fills { figcraft_engine::cmd::lists::character_fills(n) } else { Some(n.strokes.clone()) }
}
/// The paint open in the colour picker, else the image fill in crop mode (while its layer is the
/// selection).
fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRect)> {
@@ -53,7 +62,7 @@ fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRec
};
let doc = app.session.doc().ok()?;
let n = doc.node(id)?;
let list = if fills { &n.fills } else { &n.strokes };
let list = edited_list(n, fills)?;
let PaintKind::Image(img) = &list.get(index)?.kind else { return None };
if img.scale_mode != ImageScaleMode::Crop {
return None;
@@ -220,7 +229,7 @@ fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color:
return None;
}
let n = app.session.doc().ok()?.node(id)?;
let list = if *fills { &n.fills } else { &n.strokes };
let list = edited_list(n, *fills)?;
match &list.get(*index)?.kind {
PaintKind::Gradient(g) => Some((id, *fills, *index, g.clone())),
_ => None,
@@ -260,7 +269,7 @@ fn line_param(a: Pos2, b: Pos2, p: Pos2) -> Option<(f64, f32)> {
fn set_gradient(app: &mut FigApp, id: NodeId, fills: bool, index: usize, g: figcraft_color::Gradient) {
let Some(n) = app.session.doc().ok().and_then(|d| d.node(id)).cloned() else { return };
let list = if fills { &n.fills } else { &n.strokes };
let Some(list) = edited_list(&n, fills) else { return };
let Some(mut paint) = list.get(index).cloned() else { return };
paint.kind = PaintKind::Gradient(g);
let cmd = if fills { "fill.set" } else { "stroke.set" };
@@ -684,7 +693,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
Drag::Gradient { id, fills, index, which } => {
let Some(u) = screen_to_unit(app, *id, pos) else { return };
let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return };
let list = if *fills { &n.fills } else { &n.strokes };
let Some(list) = edited_list(&n, *fills) else { return };
let Some(mut paint) = list.get(*index).cloned() else { return };
if let PaintKind::Gradient(g) = &mut paint.kind {
match which {
@@ -759,7 +768,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
(ax.min(ax + nw), ay.min(ay + nh), ax.max(ax + nw), ay.max(ay + nh))
};
let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return };
let list = if *fills { &n.fills } else { &n.strokes };
let Some(list) = edited_list(&n, *fills) else { return };
let Some(mut paint) = list.get(*index).cloned() else { return };
if let PaintKind::Image(img) = &mut paint.kind {
img.crop = Some(ImagePaint::crop_from_rect(r.0, r.1, r.2, r.3));
@@ -781,3 +790,6 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
}
}
}
#[cfg(test)]
mod character_fill_tests;
@@ -0,0 +1,40 @@
//! The canvas crop handles on a text whose characters share an image fill the layer doesn't have
//! (#1856): they show and edit the characters' image, the one the Fill section and the colour
//! picker show, not the layer's own fill at that index.
use base64::Engine as _;
use figcraft_color::{Color, ImageScaleMode, Paint, PaintKind};
use figcraft_engine::Session;
use serde_json::json;
use super::crop_target;
use crate::colorpicker::PickerTarget;
use crate::layers_drag_tests::Ui;
use crate::present_tests::id;
#[test]
fn the_crop_handles_follow_the_characters_shared_image_in_crop_mode() {
let mut s = Session::new();
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
s.execute("fill.set", &json!({"ids": [t.0], "color": "#00FF00"})).unwrap();
let image = image::RgbaImage::from_pixel(40, 20, image::Rgba([200, 30, 30, 255]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
s.execute("image.place", &json!({"data": data, "target": t.0, "mode": "fill"})).unwrap();
s.execute("image.crop", &json!({"ids": [t.0], "index": 1})).unwrap();
// The cropped image goes to the characters; the layer keeps only its green.
let cropped = s.doc().unwrap().node(t).unwrap().fills[1].clone();
s.execute("text.style", &json!({"id": t.0, "start": 0, "end": 5, "textFills": [cropped]})).unwrap();
s.doc_mut().unwrap().get_mut(t).unwrap().fills = vec![Paint::solid(Color::from_hex("#00FF00").unwrap())];
let mut u = Ui::new(s);
// The picker on the Fill section's row: the characters' image, already in Crop mode.
u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![t] });
let (at, fills, index, img, _) = crop_target(&u.app).expect("the handles show the characters' image");
assert_eq!((at, fills, index), (t, true, 0));
assert_eq!(img.scale_mode, ImageScaleMode::Crop);
assert!(
matches!(u.app.session.doc().unwrap().node(t).unwrap().fills[0].kind, PaintKind::Solid { .. }),
"the layer's own fill is not what they show"
);
}
+2
View File
@@ -249,6 +249,8 @@ mod text_enter_tests;
#[cfg(test)]
mod text_link_tests;
#[cfg(test)]
mod text_mixed_fill_tests;
#[cfg(test)]
mod titlebar_tests;
#[cfg(test)]
mod ui_params_tests;
+2 -1
View File
@@ -293,7 +293,8 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
}
let t = Tokens::get(ui.ctx());
let ids = sel.to_vec();
let list = common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() });
// A text's fills are what its characters show: one list they share, or mixed (#1856).
let list = common(doc, sel, |n| if fills { figcraft_engine::cmd::lists::character_fills(n) } else { Some(n.strokes.clone()) }).flatten();
let title = if fills { tr("Fill") } else { tr("Stroke") };
let empty = list.as_ref().is_none_or(|l| l.is_empty());
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
+107
View File
@@ -0,0 +1,107 @@
//! The Fill section on a text whose characters were coloured while editing (#1856), driven
//! through egui headless: characters with different fills show as mixed content and + replaces
//! them on every character; characters that all share fills the layer doesn't have show those.
use egui::accesskit::Role;
use egui::{Pos2, pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
const NOTE: &str = "Click + to replace mixed content";
/// A red "Hello" with its characters from `from` on coloured yellow while editing, selected.
fn ui(from: usize) -> (Ui, NodeId) {
let mut s = Session::new();
let t = NodeId(s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"ids": [t], "color": "#FF0000"})).unwrap();
s.execute("text.style", &json!({"id": t, "start": from, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
(u, t)
}
/// The accessible controls after a frame: role, label, value and centre.
fn controls(u: &mut Ui) -> Vec<(Role, String, String, Pos2)> {
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
nodes
.iter()
.map(|(_, n)| {
let at = n.bounds().map_or(pos2(0.0, 0.0), |b| pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32));
(n.role(), n.label().unwrap_or_default().to_string(), n.value().unwrap_or_default().to_string(), at)
})
.collect()
}
/// What the Fill section shows: its hex rows top first, whether the mixed-content note is there
/// (only a paint section shows it, and the strokes here are never mixed), and its + button. The
/// section's rows lie between its + and the Stroke section's; Selection colors is further down.
fn fill_section(c: &[(Role, String, String, Pos2)]) -> (Vec<String>, bool, Pos2) {
let button = |name: &str| {
c.iter().find(|(r, l, _, _)| *r == Role::Button && l == name).map(|(_, _, _, p)| *p).unwrap_or_else(|| panic!("no {name} button: {c:?}"))
};
let (top, bottom) = (button("Add fill"), button("Add stroke"));
let mut rows: Vec<(f32, String)> =
c.iter().filter(|(r, _, _, p)| *r == Role::TextInput && p.y > top.y && p.y < bottom.y).map(|(_, _, v, p)| (p.y, v.clone())).collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let note = c.iter().any(|(_, l, v, _)| l == NOTE || v == NOTE);
(rows.into_iter().map(|(_, v)| v).collect(), note, top)
}
fn fills_shown(u: &Ui, t: NodeId) -> Vec<String> {
let n = u.app.session.doc().unwrap().node(t).unwrap().clone();
let tx = n.kind.text().unwrap().clone();
(0..tx.char_count())
.map(|i| {
tx.style_at(i)
.fills
.clone()
.unwrap_or_else(|| n.fills.clone())
.iter()
.map(|p| p.solid_color().unwrap().to_hex())
.collect::<Vec<_>>()
.join("+")
})
.collect()
}
#[test]
fn differently_coloured_characters_show_as_mixed_content_and_plus_replaces_them() {
let (mut u, t) = ui(1);
let c = controls(&mut u);
let (rows, note, plus) = fill_section(&c);
assert!(note, "the Fill section shows the note: {c:?}");
assert!(rows.is_empty(), "no row for the layer's own red: {rows:?}");
// + adds a fill on top of the layer's: every character shows the layer's fills now.
let steps = u.app.session.ds().unwrap().history.undo.len();
u.button(plus, true);
u.button(plus, false);
u.frame(vec![]);
let n = u.app.session.doc().unwrap().node(t).unwrap().clone();
assert_eq!(n.fills.len(), 2, "the new fill on the layer's: {:?}", n.fills);
assert!(n.kind.text().unwrap().runs.is_empty(), "no character keeps fills of its own");
assert!(fills_shown(&u, t).iter().all(|f| f == "FF0000+D9D9D9"), "{:?}", fills_shown(&u, t));
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one undo step");
let c = controls(&mut u);
let (rows, note, _) = fill_section(&c);
assert!(!note, "no note once the characters agree: {c:?}");
assert_eq!(rows, ["D9D9D9", "FF0000"], "the new fill above the layer's red: {c:?}");
}
#[test]
fn characters_sharing_fills_the_layer_lacks_show_theirs() {
let (mut u, t) = ui(0);
let n = u.app.session.doc().unwrap().node(t).unwrap().clone();
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF0000", "the layer still has its old fill");
assert_eq!(fills_shown(&u, t), ["FFFF00"; 5]);
let c = controls(&mut u);
let (rows, note, _) = fill_section(&c);
assert_eq!(rows, ["FFFF00"], "the characters' yellow is the row, not the layer's red: {c:?}");
assert!(!note, "{c:?}");
}
+2 -1
View File
@@ -92,7 +92,8 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
},
PickerTarget::Paint { fills, index, ids } => {
let n = doc.node(*ids.first()?)?;
let list = if *fills { &n.fills } else { &n.strokes };
// A text's fills are its characters' (none to pick while they differ).
let list = if *fills { figcraft_engine::cmd::lists::character_fills(n)? } else { n.strokes.clone() };
list.get(*index).cloned()
}
PickerTarget::Effect { ids, index, secondary } => doc.node(*ids.first()?)?.effects.get(*index)?.color(*secondary).map(Paint::solid),
+3 -1
View File
File diff suppressed because one or more lines are too long