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:
parent
840d3ac5d2
commit
b5db089db6
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -741,4 +741,6 @@ mod style_tests;
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod text_fill_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_tests;
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
Loading…
Reference in New Issue