diff --git a/ROADMAP.md b/ROADMAP.md index d5b8c8f..e4422d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de3eae3..95f8083 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -741,4 +741,6 @@ mod style_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_fill_tests; +#[cfg(test)] mod variable_tests; diff --git a/crates/engine/src/text_fill_tests.rs b/crates/engine/src/text_fill_tests.rs new file mode 100644 index 0000000..8501a6c --- /dev/null +++ b/crates/engine/src/text_fill_tests.rs @@ -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())); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 12d37a2..ae6c7ee 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -14,8 +14,28 @@ use crate::widgets; #[derive(Clone, Debug, PartialEq)] pub enum PickerTarget { PageBackground, - Paint { fills: bool, index: usize, ids: Vec }, - Effect { ids: Vec, index: usize }, + Paint { + fills: bool, + index: usize, + ids: Vec, + }, + Effect { + ids: Vec, + 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> { + 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 { @@ -34,6 +54,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { 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()); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 5a07159..5ad3f28 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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> = 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() });