mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
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:
12 files changed
+344
-15
No files matched your search
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic wiLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -33,6 +33,7 @@ interaction feel not yet compared.
|
||||
| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open, and so does a drag of the open gradient's handles or stops on the canvas (#1852). |
|
||||
| Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker |
|
||||
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
|
||||
| Fill section on a text with coloured characters | done | a text's Fill section shows what its characters show: characters coloured differently while editing are mixed content ("Click + to replace mixed content", as a mixed selection; the developer documentation's `fills`: "Text nodes can have multiple sets of fills if some characters are colored differently than others"), and characters that all share fills the layer doesn't have show those. Any layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, starting from the list they showed; the characters' own fills go in the same undo step; the canvas gradient and crop handles show and edit the paint the row shows, the characters' own, also when their image is already in Crop mode (`text_mixed_fill_tests`, `canvas::handles::character_fill_tests`, engine `text_fill_tests`; #1856). A range selected while editing keeps its own Fill row |
|
||||
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
|
||||
|
||||
## Layers and Assets panels
|
||||
@@ -180,6 +181,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | patch | Fill section: a text whose characters were coloured differently while editing shows mixed content, as a mixed selection does ("Text nodes can have multiple sets of fills if some characters are colored differently than others", the developer documentation's `fills`); characters that all share fills the layer doesn't have (all of them styled while editing) show those instead of the layer's older list; and a layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, their own fills going in the same undo step. The section used to show the layer's own fill, a single colour the canvas no longer showed (#1856). Tests: `text_mixed_fill_tests`, engine `text_fill_tests`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties copied the selected layer's properties into the Paste properties clipboard and nothing else, so a new rectangle drawn afterwards was still grey (and what Copy properties had copied was lost). The defaults are now kept apart (`Session::default_props`, one set for shapes and one for text): rectangles, ellipses, polygons and stars drawn or clicked with their tools take the shape defaults (`apply_copied_props`, the same rules as Paste properties: corners only where a layer has them), and new text from the Text tool takes a text layer's fills and font and is laid out at that size. Frames, sections, slices, lines, arrows, vectors and layers made by commands keep their own look. `edit.setDefaultProperties` answers `{for: shapes|text, properties}`. Tests: `default_props_tests` (3) |
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. |
|
||||
|
||||
Reference in new issue
Block a user