Stack on #1857: merge fix/text-mixed-fill-section

Both redirect the Fill section's reads and writes: the selected regions of a vector come first (#1878), else a text's characters' shared fills (#1857), in the Fill section, the colour picker, the canvas handles, fill.copy, fill.set and fill.move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Opus 5.5 committed 2026-11-03 01:12:08 -05:00
commit b936b95fd9
12 files changed
+341 -18

No files matched your search

+19 -4
View File
@@ -349,6 +349,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 {
@@ -421,6 +433,9 @@ fn paint_set_in(s: &mut Session, p: &Value, fills: bool, to_regions: bool) -> Re
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() {
@@ -449,13 +464,13 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
let doc = s.doc()?;
let n = doc.get(id)?;
// Regions selected in vector edit mode: their own fills, as the Fill section lists them
// (#1875).
// (#1875); a text's fills are what its characters show (#1856).
let regions = if fills { crate::vectoredit::selected_region_fills(s, id) } else { None };
let list = match &regions {
let list: Vec<figcraft_color::Paint> = match regions {
Some(Some(list)) => list,
Some(None) => return Err(bad(cmd, "the selected regions' fills differ")),
None if fills => &n.fills,
None => &n.strokes,
None if fills => crate::cmd::lists::character_fills(n).unwrap_or_default(),
None => 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" })))?;
+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 {
@@ -263,11 +309,19 @@ fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result<V
return ok();
}
for id in ids {
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
@@ -376,6 +376,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();
+10 -5
View File
@@ -40,15 +40,18 @@ 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`: the selected regions' own
/// fills while regions of it are selected in vector edit mode, as the Fill section lists them and
/// `fill.set` edits them (#1875), else the layer's fills or strokes. None when those regions'
/// fills differ.
/// The paint list a picker paint at an index belongs to on layer `n`, as the Fill section and the
/// colour picker list it: the selected regions' own fills while regions of it are selected in
/// vector edit mode (#1875); else, for fills, a text's characters' shared fills (`character_fills`:
/// the layer's own when the characters have none, #1856), or the layer's strokes. None when those
/// regions' or characters' fills differ. Writes go through `fill.set`, which edits the selected
/// regions or moves the characters' list to the layer first, so the handles read and write the
/// same paint.
fn edited_list(app: &FigApp, n: &figcraft_doc::Node, fills: bool) -> Option<Vec<Paint>> {
if fills && let Some(regions) = figcraft_engine::vectoredit::selected_region_fills(&app.session, n.id) {
return regions;
}
Some(if fills { n.fills.clone() } else { n.strokes.clone() })
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
@@ -761,5 +764,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
}
}
#[cfg(test)]
mod character_fill_tests;
#[cfg(test)]
mod region_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
@@ -247,6 +247,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;
+4 -1
View File
@@ -302,7 +302,10 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
// The fill style, a variable bound to the layer's fill and the image crop are the layer's:
// they stay off a region's rows.
let region = region_list.is_some();
let list = region_list.unwrap_or_else(|| common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() }));
// Else a text's fills are what its characters show: one list they share, or mixed (#1856).
let list = region_list.unwrap_or_else(|| {
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
@@ -99,7 +99,8 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
{
return list.get(*index).cloned();
}
let list = if *fills { &n.fills } else { &n.strokes };
// A text's fills are its characters' (none to pick while they differ, #1856).
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
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); 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 with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their tLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); 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); 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 with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); sLine 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. |
| 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
@@ -178,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer ("If there are multiple closed regions within a vector network, you can adjust the fill for each region individually", Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. |
| 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-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |