M4: text range fills in the Fill section (#18)

- While a range of text is selected in a text being edited, the Fill section shows that range's
  colour ("Mixed" when it varies): the swatch opens the colour picker on the range, the hex field
  sets it, and a button returns the range to the layer's fill.
- Colour picker: a text-range target that reads the range's fill and writes `text.style
  {textFills}`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 03:52:11 +07:00 committed by GitHub
parent 840d3ac5d2
commit b5db089db6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 112 additions and 5 deletions

View File

@ -49,8 +49,8 @@ dimension measured side by side.
inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the
document only gets the committed text), variable fonts (axes and named instances read from the
font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied
to shaping, outlines and PDF), range-aware Typography panel with "Mixed". *Left:* range fills in
the Fill section, axis hover preview, right-to-left/bidi text, visual QA. 6–10 h.
to shaping, outlines and PDF), range-aware Typography panel with "Mixed". Range fills in the Fill
section. *Left:* axis hover preview, right-to-left/bidi text, visual QA. 6–10 h.
3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid
column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text
wraps at its max), ignore auto layout (absolute position) with constraints, text baseline
@ -137,7 +137,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
| Frames, groups, sections, masks | 5 | 70% | section tooling (section presets, auto-size to contents, section-level properties) | 3–5 |
| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 |
| Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 |
| Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 |
| Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 |
| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 |
| Prototyping | 6 | 60% | overlay close animation, multi-selection editing, scrolling inside device frames | 6–10 |

View File

@ -741,4 +741,6 @@ mod style_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_fill_tests;
#[cfg(test)]
mod variable_tests;

View File

@ -0,0 +1,24 @@
//! Range fills on text: set on a range, cleared back to the layer's fill.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
#[test]
fn range_fill_sets_and_clears() {
let mut s = Session::new();
let t = id_of(&s.execute("text.create", &json!({"text": "Hello brand world", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "textFills": ["#0D99FF"]})).unwrap();
let fill_at = |s: &Session, i: usize| {
let n = s.doc().unwrap().node(t).unwrap().clone();
let tx = n.kind.text().unwrap().clone();
tx.style_at(i).fills.clone().unwrap_or(n.fills).first().and_then(|p| p.solid_color()).map(|c| c.to_hex())
};
assert_eq!(fill_at(&s, 7), Some("0D99FF".into()));
assert_ne!(fill_at(&s, 1), Some("0D99FF".into()));
s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "textFills": null})).unwrap();
assert_eq!(fill_at(&s, 7), fill_at(&s, 1), "back to the layer's fill");
s.undo().unwrap();
assert_eq!(fill_at(&s, 7), Some("0D99FF".into()));
}

View File

@ -14,8 +14,28 @@ use crate::widgets;
#[derive(Clone, Debug, PartialEq)]
pub enum PickerTarget {
PageBackground,
Paint { fills: bool, index: usize, ids: Vec<NodeId> },
Effect { ids: Vec<NodeId>, index: usize },
Paint {
fills: bool,
index: usize,
ids: Vec<NodeId>,
},
Effect {
ids: Vec<NodeId>,
index: usize,
},
/// The fill of a character range of a text layer being edited.
TextRange {
id: NodeId,
start: usize,
end: usize,
},
}
/// The fills a text range paints with (its own, else the layer's).
pub fn range_fills(doc: &figcraft_doc::Document, id: NodeId, start: usize) -> Option<Vec<Paint>> {
let n = doc.node(id)?;
let t = n.kind.text()?;
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
}
fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
@ -34,6 +54,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => Some(Paint::solid(s.color)),
_ => None,
},
PickerTarget::TextRange { id, start, .. } => range_fills(doc, *id, *start)?.into_iter().next(),
}
}
@ -59,6 +80,9 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
}
app.session.execute("effect.set", &json!({"ids": ids, "index": index, "effect": e2}))
}
PickerTarget::TextRange { id, start, end } => {
app.session.execute("text.style", &json!({"id": id, "start": start, "end": end, "textFills": [paint]}))
}
};
if let Err(e) = r {
app.toast(e.to_string());

View File

@ -591,7 +591,64 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.add_space(8.0);
}
/// While a range of text is selected, the Fill section edits that range's colour.
fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: usize, end: usize) {
let t = Tokens::get(ui.ctx());
widgets::section_header(ui, "Fill", &[], false);
// The fills over the range, if every character shares them.
let fills: Option<Vec<Paint>> = doc.node(id).and_then(|n| n.kind.text().map(|tx| (n, tx))).and_then(|(n, tx)| {
let mut it =
tx.spans().into_iter().filter(|(s, e, _)| *s < end && *e > start).map(|(_, _, st)| st.fills.clone().unwrap_or_else(|| n.fills.clone()));
let first = it.next()?;
it.all(|f| f == first).then_some(first)
});
row(ui, 32.0, |ui| {
let w = ui.available_width() - 28.0;
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 4.0, r.max.y));
match fills.as_ref().and_then(|f| f.first()) {
Some(p) => {
widgets::paint_swatch(ui.painter(), sw, p);
if ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click()).clicked() {
app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });
}
if let Some(c) = p.solid_color() {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
if let Some(h) =
widgets::text_field_inline(&mut child, Id::new("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
&& let Some(nc) = figcraft_color::Color::from_hex(&h)
{
app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]}));
}
} else {
ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, "Gradient", regular(11.0), t.text);
}
}
None => {
ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, "Mixed", regular(11.0), t.text_secondary);
if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() {
app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });
}
}
}
// Back to the layer's fill.
if icons::button(ui, Icon::Detach, 24.0, false, "Use the layer's fill").clicked() {
app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": null}));
}
});
}
fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) {
if fills
&& let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session)
&& a < b
&& sel.contains(&id)
{
range_fill(app, ui, doc, id, a, b);
return;
}
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() });