mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Text: drag a Range Selector's Start and End in the Composition viewer (#1011)
This commit is contained in:
+18
-10
@@ -147,15 +147,6 @@ fn unit_of(based: BasedOn, g: &CharGlyph, lay: &TextLayout) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_count(based: BasedOn, lay: &TextLayout) -> usize {
|
||||
match based {
|
||||
BasedOn::Characters => lay.chars,
|
||||
BasedOn::CharactersExcludingSpaces => lay.chars_no_space,
|
||||
BasedOn::Words => lay.words,
|
||||
BasedOn::Lines => lay.lines,
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_seed(layer: &Layer, seed: f64) -> u64 {
|
||||
(seed as i64 as u64) ^ layer.id.0.wrapping_mul(0x9E37_79B9)
|
||||
}
|
||||
@@ -179,6 +170,23 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang
|
||||
}
|
||||
}
|
||||
|
||||
/// A Range Selector as the viewer's bars see it (#1011): Based On, whether Units is Index, and
|
||||
/// Start / End / Offset as the fractions the renderer uses.
|
||||
pub fn range_bars(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, lay: &TextLayout) -> (BasedOn, bool, Range) {
|
||||
let adv = sel.sub("advanced");
|
||||
let based = BasedOn::from_index(adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0));
|
||||
let index = adv.is_some_and(|a| ctx.e(layer, a, "units") == 1);
|
||||
(based, index, range_params(ctx, layer, sel, lay.units(based)))
|
||||
}
|
||||
|
||||
/// The Start / End value that puts a Range Selector bar at fraction `f` of the `n` units, with
|
||||
/// Offset `offset` (a fraction): the bar's place less the Offset, within 0..100%, or in whole
|
||||
/// units with Units = Index.
|
||||
pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 {
|
||||
let v = (f - offset).clamp(0.0, 1.0);
|
||||
if units_index { (v * n as f64).round() } else { v * 100.0 }
|
||||
}
|
||||
|
||||
/// Selection of every glyph by an animator's selectors, per dimension.
|
||||
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
|
||||
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
|
||||
@@ -199,7 +207,7 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &
|
||||
};
|
||||
let mode = Mode::from_index(mode);
|
||||
let based = BasedOn::from_index(based);
|
||||
let n = unit_count(based, lay);
|
||||
let n = lay.units(based);
|
||||
let values: Box<dyn Fn(usize, usize, [f64; 3]) -> [f64; 3]> = match sel.match_id.as_str() {
|
||||
"rangeSelector" => {
|
||||
let r = range_params(ctx, layer, sel, n);
|
||||
|
||||
@@ -550,3 +550,17 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
let multiply = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Multiply).get(10, 10);
|
||||
assert!(multiply[3] < 0.9, "{multiply:?}");
|
||||
}
|
||||
|
||||
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
|
||||
#[test]
|
||||
fn range_bar_drag_values() {
|
||||
// Percentage: the bar's place less the Offset, within 0..100.
|
||||
assert!((range_value_of(0.5, 0.0, false, 7) - 50.0).abs() < 1e-9);
|
||||
assert!((range_value_of(0.5, 0.25, false, 7) - 25.0).abs() < 1e-9, "Offset 25%: the bar at 50% is Start 25%");
|
||||
assert_eq!(range_value_of(0.1, 0.25, false, 7), 0.0, "never below 0");
|
||||
assert_eq!(range_value_of(1.0, -0.5, false, 7), 100.0, "nor above 100");
|
||||
// Index: whole units, the nearest boundary (3.4 of 7 characters → 3, 3.6 → 4).
|
||||
assert_eq!(range_value_of(3.4 / 7.0, 0.0, true, 7), 3.0);
|
||||
assert_eq!(range_value_of(3.6 / 7.0, 0.0, true, 7), 4.0);
|
||||
assert_eq!(range_value_of(3.6 / 7.0, 2.0 / 7.0, true, 7), 2.0, "less a 2-character Offset");
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justi
|
||||
pub use fonts::{FaceId, Resolved, families, resolve};
|
||||
use kurbo::{Affine, BezPath, Point};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure};
|
||||
use selectors::BasedOn;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
@@ -134,6 +135,8 @@ pub struct TextLayout {
|
||||
pub to_layer: Affine,
|
||||
/// Byte offset of every character, plus the text length.
|
||||
pub byte_of_char: Vec<usize>,
|
||||
/// Whether each character is white space (for the selectors' Based On).
|
||||
pub space_of_char: Vec<bool>,
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
@@ -262,6 +265,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
}
|
||||
in_word = !space;
|
||||
word_of_char.push(words.saturating_sub(1));
|
||||
out.space_of_char.push(space);
|
||||
nospace_of_char.push(nospace);
|
||||
if !space {
|
||||
nospace += 1;
|
||||
@@ -415,6 +419,99 @@ impl TextLayout {
|
||||
None => (0, self.chars),
|
||||
}
|
||||
}
|
||||
/// The number of selector units for Based On `based`.
|
||||
pub fn units(&self, based: BasedOn) -> usize {
|
||||
match based {
|
||||
BasedOn::Characters => self.chars,
|
||||
BasedOn::CharactersExcludingSpaces => self.chars_no_space,
|
||||
BasedOn::Words => self.words,
|
||||
BasedOn::Lines => self.lines,
|
||||
}
|
||||
}
|
||||
/// Characters `a..b` of every selector unit, in order; words and Characters Excluding Spaces
|
||||
/// leave the spaces out.
|
||||
fn unit_spans(&self, based: BasedOn) -> Vec<(usize, usize)> {
|
||||
let sp = |ci: usize| self.space_of_char.get(ci).copied().unwrap_or(false);
|
||||
match based {
|
||||
BasedOn::Characters => (0..self.chars).map(|ci| (ci, ci + 1)).collect(),
|
||||
BasedOn::CharactersExcludingSpaces => (0..self.chars).filter(|&ci| !sp(ci)).map(|ci| (ci, ci + 1)).collect(),
|
||||
BasedOn::Words => {
|
||||
let (mut out, mut start) = (vec![], None);
|
||||
for ci in 0..self.chars {
|
||||
match (sp(ci), start) {
|
||||
(false, None) => start = Some(ci),
|
||||
(true, Some(a)) => {
|
||||
out.push((a, ci));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.extend(start.map(|a| (a, self.chars)));
|
||||
out
|
||||
}
|
||||
BasedOn::Lines => self.layout.lines.iter().map(|l| (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l)))).collect(),
|
||||
}
|
||||
}
|
||||
/// The carets at the leading and trailing edges of the unit made of characters `a..b`: before
|
||||
/// its first character, and after its last one when that is on the same line, else the line's
|
||||
/// end. Carets rather than glyphs, so the edges also fall inside ligatures and follow
|
||||
/// right-to-left runs.
|
||||
fn unit_edges(&self, (a, b): (usize, usize)) -> Option<(usize, usize)> {
|
||||
let li = self.line_of_char(a);
|
||||
let trail = if self.line_of_char(b) == li { b } else { self.char_of_byte(self.layout.line_end(self.layout.lines.get(li)?)) };
|
||||
Some((a, trail))
|
||||
}
|
||||
/// The caret at `ci` as (top, baseline, bottom) in layer space.
|
||||
fn caret_points(&self, ci: usize) -> [Point; 3] {
|
||||
let (top, bottom) = self.caret(ci);
|
||||
let (x, base, _) = self.layout.caret(self.byte(ci));
|
||||
[top, self.to_layer * Point::new(x as f64, base as f64), bottom]
|
||||
}
|
||||
/// A Range Selector bar at fraction `f` of the units (#1011): a caret-like segment as (top,
|
||||
/// baseline, bottom) in layer space, between unit boundaries in proportion. None for empty or
|
||||
/// vertical text.
|
||||
pub fn range_bar(&self, based: BasedOn, f: f64) -> Option<[Point; 3]> {
|
||||
let n = self.units(based);
|
||||
if self.vertical || n == 0 || !f.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let x = f.clamp(0.0, 1.0) * n as f64;
|
||||
let u = (x.floor() as usize).min(n - 1);
|
||||
let t = x - u as f64;
|
||||
let (a, z) = self.unit_edges(*self.unit_spans(based).get(u)?)?;
|
||||
let (lead, trail) = (self.caret_points(a), self.caret_points(z));
|
||||
Some([0, 1, 2].map(|k| lead[k].lerp(trail[k], t)))
|
||||
}
|
||||
/// The fraction of the units under a layer-space point, the inverse of [`Self::range_bar`]:
|
||||
/// on the line whose baseline is nearest, the nearest place between unit edges.
|
||||
pub fn range_fraction_at(&self, based: BasedOn, p: Point) -> Option<f64> {
|
||||
let n = self.units(based);
|
||||
if self.vertical || n == 0 {
|
||||
return None;
|
||||
}
|
||||
let q = self.to_layer.inverse() * p;
|
||||
let line = self.layout.lines.iter().enumerate().min_by(|a, b| (a.1.baseline as f64 - q.y).abs().total_cmp(&(b.1.baseline as f64 - q.y).abs()))?.0;
|
||||
let spans = self.unit_spans(based);
|
||||
let nearest = |on_line: bool| {
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (u, span) in spans.iter().enumerate() {
|
||||
if on_line && self.line_of_char(span.0) != line {
|
||||
continue;
|
||||
}
|
||||
let Some((a, z)) = self.unit_edges(*span) else { continue };
|
||||
let (x0, x1) = (self.caret(a).1.x, self.caret(z).1.x);
|
||||
let t = if (x1 - x0).abs() > 1e-9 { ((p.x - x0) / (x1 - x0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = (x0 + (x1 - x0) * t - p.x).abs();
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, (u as f64 + t) / n as f64));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
};
|
||||
// (a line without units, such as an empty one, takes the nearest unit anywhere)
|
||||
nearest(true).or_else(|| nearest(false))
|
||||
}
|
||||
/// Selection highlight quads (layer space) for characters `a..b`.
|
||||
pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||||
self.layout
|
||||
@@ -634,4 +731,73 @@ mod tests {
|
||||
assert!(l.bounds[0] < -100.0 && l.bounds[2] > 100.0, "{:?}", l.bounds);
|
||||
assert!((l.bounds[0] + l.bounds[2]).abs() < 20.0);
|
||||
}
|
||||
|
||||
// #1011: a Range Selector's bars in the Composition viewer.
|
||||
fn bar_x(l: &TextLayout, based: selectors::BasedOn, f: f64) -> f64 {
|
||||
l.range_bar(based, f).unwrap()[2].x
|
||||
}
|
||||
|
||||
fn doc(text: &str) -> TextLayout {
|
||||
layout_doc(&TextDoc { text: text.into(), size: 40.0, ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bars_sit_on_unit_boundaries() {
|
||||
use selectors::BasedOn::*;
|
||||
let l = doc("abcd");
|
||||
for (f, ci) in [(0.0, 0), (0.5, 2), (1.0, 4)] {
|
||||
assert!((bar_x(&l, Characters, f) - l.caret(ci).1.x).abs() < 1e-6, "{f} at caret {ci}");
|
||||
}
|
||||
let mid = (l.caret(0).1.x + l.caret(1).1.x) / 2.0;
|
||||
assert!((bar_x(&l, Characters, 0.125) - mid).abs() < 1e-6, "half way through the first character");
|
||||
// Words and Characters Excluding Spaces skip the space.
|
||||
let l = doc("ab cd");
|
||||
assert!((bar_x(&l, Words, 0.5) - l.caret(3).1.x).abs() < 1e-6, "the second word starts at c");
|
||||
assert!((bar_x(&l, Words, 0.25) - (l.caret(0).1.x + l.caret(2).1.x) / 2.0).abs() < 1e-6, "half of ab, no space");
|
||||
assert!((bar_x(&l, CharactersExcludingSpaces, 0.5) - l.caret(3).1.x).abs() < 1e-6, "c is the third of four");
|
||||
assert!(doc("").range_bar(Characters, 0.0).is_none());
|
||||
let v = layout_doc(&TextDoc { text: "ab".into(), vertical: true, ..Default::default() });
|
||||
assert!(v.range_bar(Characters, 0.0).is_none(), "vertical text has no bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bars_on_the_right_line_ligatures_and_rtl() {
|
||||
use selectors::BasedOn::*;
|
||||
let l = doc("ab\rcd");
|
||||
// 5 characters (the CR counts): 80% is the start of d, on the second line.
|
||||
let [top, base, bottom] = l.range_bar(Characters, 0.8).unwrap();
|
||||
assert_eq!((top, bottom), l.caret(4));
|
||||
assert!(top.y < base.y && base.y < bottom.y && (base.y - bottom.y + l.layout.lines[1].descent as f64).abs() < 1e-6, "the baseline");
|
||||
assert!(bottom.y > l.caret(0).1.y + 10.0, "below the first line");
|
||||
let [top, _, bottom] = l.range_bar(Lines, 0.5).unwrap();
|
||||
assert_eq!((top, bottom), l.caret(3), "the second line starts at c");
|
||||
// Inside a ligature ("ffl") the bars still move character by character.
|
||||
let l = doc("Offline");
|
||||
let xs: Vec<f64> = [1.0, 2.0, 3.0, 4.0].iter().map(|k| bar_x(&l, Characters, k / 7.0)).collect();
|
||||
assert!(xs.windows(2).all(|w| w[1] > w[0]), "{xs:?}");
|
||||
// Right to left: Start is on the right of the first character.
|
||||
let mut r = TextDoc { text: "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}".into(), size: 40.0, ..Default::default() };
|
||||
r.direction = effectcraft_keyframe::text_doc::Direction::Rtl;
|
||||
let r = layout_doc(&r);
|
||||
assert!(bar_x(&r, Characters, 0.0) > bar_x(&r, Characters, 0.2) && bar_x(&r, Characters, 0.2) > bar_x(&r, Characters, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_fraction_at_inverts_range_bar() {
|
||||
use selectors::BasedOn::*;
|
||||
// (Fractions off the CR, a unit with no width whose bar is the line's end.)
|
||||
for (text, based) in [("ab\rcd", Characters), ("one two\rthree", Words), ("Offline", Characters)] {
|
||||
let l = doc(text);
|
||||
for f in [0.1, 0.3, 0.9] {
|
||||
let [top, _, bottom] = l.range_bar(based, f).unwrap();
|
||||
let p = Point::new(bottom.x, (top.y + bottom.y) / 2.0);
|
||||
let back = l.range_fraction_at(based, p).unwrap();
|
||||
assert!((back - f).abs() < 1e-6, "{text}: {f} → {back}");
|
||||
}
|
||||
}
|
||||
// The nearest line by its baseline: far left of the second line is the start of c.
|
||||
let l = doc("ab\rcd");
|
||||
let base = l.caret(3).1.y - l.layout.lines[1].descent as f64;
|
||||
assert!((l.range_fraction_at(Characters, Point::new(-500.0, base)).unwrap() - 0.6).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fn gradient_paints<'a>(g: &'a PropGroup, depth: usize, out: &mut Vec<&'a PropGro
|
||||
}
|
||||
|
||||
/// `g` or something inside it is selected on `layer`.
|
||||
fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool {
|
||||
pub(crate) fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool {
|
||||
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
|
||||
g.uid == uid || (depth < 16 && g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(c) if contains(c, uid, depth + 1))))
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod settings;
|
||||
pub mod shortcut_editor;
|
||||
pub mod templates;
|
||||
pub mod text_panels;
|
||||
pub mod text_range_handles;
|
||||
pub mod timeline;
|
||||
pub mod tracker;
|
||||
pub mod unsaved;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! A text layer's Range Selector Start and End in the Composition viewer (#1011). With the
|
||||
//! selector, its animator or a property in the animator selected, each enabled Range Selector of
|
||||
//! the animator shows two bars on the text, at Start + Offset and End + Offset (where the
|
||||
//! renderer applies the range), as in After Effects. With the Selection tool a drag sets Start or
|
||||
//! End with `prop.set`, leaving Offset alone: one undo step per drag, and on an animated
|
||||
//! property a keyframe at the current time. A bar whose place falls outside the text (a negative
|
||||
//! Offset, say) is pinned to its end, and dragging it writes the value for where it's dropped.
|
||||
//!
|
||||
//! The bars follow the static layout on purpose: an animator that moves the characters (a
|
||||
//! fly-in, say) would otherwise carry them out of reach. Vertical text and text on a path have
|
||||
//! none.
|
||||
//!
|
||||
//! Automation ids: `viewer.textRange.<uid>` (the Start / End property's uid).
|
||||
|
||||
use effectcraft_engine::geom::Mat3;
|
||||
use effectcraft_engine::project::{Layer, LayerSource, PropGroup};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::render::text::{range_bars, range_value_of, source_text};
|
||||
use effectcraft_engine::text::kurbo::Point;
|
||||
use effectcraft_engine::text::layout_doc;
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::fx_widgets as fw;
|
||||
use super::gradient_handles::selected_in;
|
||||
use crate::EffectcraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Draw the selected Range Selectors' bars and, with the Selection tool, drag them. `l2c` is the
|
||||
/// viewer's layer→comp matrix. Called after the viewer's own gestures, so a bar takes the press
|
||||
/// before the layer would move.
|
||||
pub fn viewer_hook(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &super::viewer::ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
|
||||
) {
|
||||
if !app.ui.viewer.show_layer_controls {
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let interactive = app.ui.tool == Tool::Selection;
|
||||
let selected_props = app.session.state.selected_props.clone();
|
||||
let selected_layers = app.session.state.selected_layers.clone();
|
||||
let editing = app.session.state.text_edit.as_ref().map(|e| e.layer);
|
||||
let mut actions: Vec<(String, serde_json::Value)> = vec![];
|
||||
for layer in ectx
|
||||
.comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| selected_layers.contains(&l.id) && l.is_active_at(ectx.time) && matches!(l.source, LayerSource::Text) && editing != Some(l.id))
|
||||
{
|
||||
let Some(text) = layer.props.sub("text") else { continue };
|
||||
if text.sub("pathOptions").is_some_and(|po| ectx.e(layer, po, "path") != 0) {
|
||||
continue;
|
||||
}
|
||||
let sels: Vec<&PropGroup> = text
|
||||
.sub("animators")
|
||||
.into_iter()
|
||||
.flat_map(|a| a.groups())
|
||||
.filter(|a| a.enabled && selected_in(a, layer.id, &selected_props))
|
||||
.filter_map(|a| a.sub("selectors"))
|
||||
.flat_map(|s| s.groups())
|
||||
.filter(|s| s.enabled && s.match_id == "rangeSelector")
|
||||
.collect();
|
||||
if sels.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(doc) = source_text(ectx, layer) else { continue };
|
||||
let lay = layout_doc(&doc);
|
||||
let m = l2c(ectx, layer);
|
||||
// The layer's bounding-box handles, registered earlier this frame: the viewer grabs one
|
||||
// within 9 px of a press, so a bar leaves it the pointer there (Start at 0% of two-line
|
||||
// text meets the left side's handle) and takes it in the rest of its zone.
|
||||
let prefix = format!("viewer.handle.{}.", layer.id.0);
|
||||
let box_handles: Vec<Pos2> = app
|
||||
.auto
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.id.starts_with(&prefix))
|
||||
.map(|e| Pos2::new(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
|
||||
.collect();
|
||||
let to_screen = |p: Point| map.to_screen(fw::layer_to_comp(&m, [p.x, p.y]));
|
||||
for sel in sels {
|
||||
let (based, index, r) = range_bars(ectx, layer, sel, &lay);
|
||||
let n = lay.units(based);
|
||||
let bars: Vec<_> = [("start", r.start), ("end", r.end)]
|
||||
.into_iter()
|
||||
.filter_map(|(k, f)| Some((sel.get(k)?, lay.range_bar(based, (f + r.offset).clamp(0.0, 1.0))?)))
|
||||
.collect();
|
||||
let [(_, [_, b0, _]), (_, [_, b1, _])] = bars[..] else { continue };
|
||||
// Along the baseline on screen (the layer may be rotated or flipped).
|
||||
let along = (to_screen(Point::new(b0.x + 1.0, b0.y)) - to_screen(b0)).normalized();
|
||||
let first = (to_screen(b1) - to_screen(b0)).dot(along) >= 0.0;
|
||||
for (k, (pr, [top, base, bottom])) in bars.iter().enumerate() {
|
||||
let (st, sp, sb) = (to_screen(*top), to_screen(*base), to_screen(*bottom));
|
||||
let up = (st - sb).normalized();
|
||||
// The bar further along the line points back, the other forward: into the range.
|
||||
let inward = if (k == 0) == first { along } else { -along };
|
||||
// The triangle stands on the baseline, clear of the box's side handles (half way
|
||||
// up the box) and its bottom ones (at the descenders).
|
||||
let tri = [sp, sp + up * 8.0, sp + up * 4.0 + inward * 6.0];
|
||||
// A short grab zone around the triangle, so the line above doesn't steal the
|
||||
// layer's scale handles.
|
||||
let hr = Rect::from_two_pos(sp, sp + up * 8.0 + inward * 6.0).expand(3.0);
|
||||
let id = egui::Id::new(("viewer-text-range", pr.uid));
|
||||
let on_box_handle = ctx.pointer_latest_pos().is_some_and(|p| box_handles.iter().any(|c| c.distance(p) < 9.0));
|
||||
let free = ctx.is_being_dragged(id) || !on_box_handle;
|
||||
let resp = (interactive && free).then(|| ui.interact(hr, id, Sense::drag()));
|
||||
if interactive {
|
||||
app.auto.add(&format!("viewer.textRange.{}", pr.uid), hr, &pr.name);
|
||||
}
|
||||
let hot = resp.as_ref().is_some_and(|r| r.hovered() || r.dragged());
|
||||
let col = if hot { t.accent } else { Color32::WHITE };
|
||||
draw_bar(painter, st, sb, &tri, Vec2::new(1.0, 1.0), Color32::from_black_alpha(160));
|
||||
draw_bar(painter, st, sb, &tri, Vec2::ZERO, col);
|
||||
let Some(resp) = resp else { continue };
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
resp.clone().on_hover_text(format!("{}: {}", sel.name, pr.name));
|
||||
}
|
||||
// Keep the grab point's distance from the bar, so the bar doesn't jump to the pointer.
|
||||
let grab = id.with("grab");
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = ctx.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(grab, pos - sp));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some(lp) = fw::comp_to_layer(&m, map.to_comp(pos - ctx.data(|d| d.get_temp::<Vec2>(grab)).unwrap_or_default()))
|
||||
&& let Some(f) = lay.range_fraction_at(based, Point::new(lp[0], lp[1]))
|
||||
{
|
||||
let key = super::effect_controls::gesture_key(ui, id, resp.drag_started());
|
||||
let value = range_value_of(f, r.offset, index, n);
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": value, "merge": key})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_bar(painter: &egui::Painter, top: Pos2, bottom: Pos2, tri: &[Pos2; 3], d: Vec2, col: Color32) {
|
||||
painter.line_segment([top + d, bottom + d], Stroke::new(1.5, col));
|
||||
painter.add(egui::Shape::convex_polygon(tri.iter().map(|p| *p + d).collect(), col, Stroke::NONE));
|
||||
}
|
||||
@@ -2020,6 +2020,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
// Selected shape gradients' start and end points (#612).
|
||||
crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
// Selected text Range Selectors' Start and End bars (#1011).
|
||||
crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
|
||||
// Status chips (render time / caching).
|
||||
if app.playback.playing && app.playback.waiting {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! #1011: a text layer's Range Selector shows its Start and End as bars on the text in the
|
||||
//! Composition viewer, as in After Effects, while the selector or its animator is selected. A
|
||||
//! drag sets Start / End in one undo step (a keyframe at the current time when animated) and
|
||||
//! doesn't move the layer. Uses only APIs that existed before the bars, so it runs (and fails)
|
||||
//! on the code without them.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::text_edit::layer_doc;
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
use effectcraft_engine::text::layout_doc;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::panels::viewer::comp_to_screen;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
struct Ids {
|
||||
layer: u64,
|
||||
animator: u64,
|
||||
color: u64,
|
||||
start: u64,
|
||||
end: u64,
|
||||
}
|
||||
|
||||
fn animator(s: &Session, l: u64) -> PropGroup {
|
||||
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap();
|
||||
layer.props.sub("text").unwrap().sub("animators").unwrap().groups().next().unwrap().clone()
|
||||
}
|
||||
|
||||
/// A 640×360 comp with the point text "abcdefgh" and a Fill Color animator (one Range Selector).
|
||||
fn setup() -> (Session, Ids) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let l = s.execute("layer.newText", json!({"text": "abcdefgh", "size": 60})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"layer": l, "property": "fillColor"})).unwrap();
|
||||
let a = animator(&s, l);
|
||||
let sel = a.sub("selectors").unwrap().groups().next().unwrap();
|
||||
let color = a.sub("properties").unwrap().children.first().unwrap().uid();
|
||||
let ids = Ids { layer: l, animator: a.uid, color, start: sel.get("start").unwrap().uid, end: sel.get("end").unwrap().uid };
|
||||
s.state.selected_layers = vec![LayerId(l)];
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn handle(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Option<Pos2> {
|
||||
h.state().auto.find(&format!("viewer.textRange.{uid}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
|
||||
}
|
||||
|
||||
fn value(h: &Harness<'_, EffectcraftApp>, ids: &Ids, uid: u64) -> f64 {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(uid).unwrap().value.as_f64()
|
||||
}
|
||||
|
||||
/// Screen x of the caret before character `ci` of the text.
|
||||
fn caret_x(h: &Harness<'_, EffectcraftApp>, ids: &Ids, ci: usize) -> f32 {
|
||||
let s = &h.state().session;
|
||||
let lay = layout_doc(&layer_doc(s, LayerId(ids.layer)).unwrap());
|
||||
let pos = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.as_vec2();
|
||||
let (_, bottom) = lay.caret(ci);
|
||||
comp_to_screen(&h.ctx, [(pos[0] + bottom.x) as f32, (pos[1] + bottom.y) as f32]).unwrap().x
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// With the Range Selector's animator selected, End is a bar at the end of the text; dragging it
|
||||
/// back to the boundary after the fourth character sets End to 50%, in one undo step, without
|
||||
/// moving Start or the layer.
|
||||
#[test]
|
||||
fn dragging_end_sets_it_in_one_undo_step() {
|
||||
let (mut s, ids) = setup();
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.animator)];
|
||||
let mut h = harness(s);
|
||||
let at = handle(&h, ids.end).unwrap_or_else(|| panic!("no viewer.textRange.{}: the Range Selector's End isn't in the viewer", ids.end));
|
||||
assert!(handle(&h, ids.start).is_some(), "and its Start");
|
||||
let position = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.clone();
|
||||
let undo = h.state().session.history.undo.len();
|
||||
let dx = caret_x(&h, &ids, 4) - caret_x(&h, &ids, 8);
|
||||
drag(&mut h, at, at + vec2(dx, 0.0));
|
||||
let end = value(&h, &ids, ids.end);
|
||||
assert!((end - 50.0).abs() < 1.0, "End at the middle of 8 characters: {end}");
|
||||
assert_eq!(value(&h, &ids, ids.start), 0.0, "Start stays");
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone();
|
||||
assert_eq!(layer.props.prop("transform/position").unwrap().value, position, "the layer didn't move");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo + 1, "the drag is one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(value(&h, &ids, ids.end), 100.0, "one undo puts it back");
|
||||
}
|
||||
|
||||
/// End animated from 0% (0 s) to 100% (2 s): at 1 s the bar sits at 50%, and dragging it adds a
|
||||
/// keyframe at 1 s with the new value; the other keys stay.
|
||||
#[test]
|
||||
fn dragging_an_animated_end_keys_the_current_time() {
|
||||
let (mut s, ids) = setup();
|
||||
s.execute("prop.addKey", json!({"layer": ids.layer, "prop": ids.end, "time": 0.0, "value": 0.0})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": ids.layer, "prop": ids.end, "time": 2.0, "value": 100.0})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.start)];
|
||||
let mut h = harness(s);
|
||||
let at = handle(&h, ids.end).expect("an End bar (selecting Start shows the selector)");
|
||||
assert!((at.x - caret_x(&h, &ids, 4)).abs() < 12.0, "at 1 s End is 50%: the bar is after the fourth character");
|
||||
let dx = caret_x(&h, &ids, 6) - caret_x(&h, &ids, 4);
|
||||
drag(&mut h, at, at + vec2(dx, 0.0));
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone();
|
||||
let keys: Vec<(f64, f64)> = layer.props.find(ids.end).unwrap().keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect();
|
||||
assert_eq!(keys.len(), 3, "{keys:?}");
|
||||
// (Times on the comp's frames: 29.97 fps puts "1 s" at 1.001 s.)
|
||||
let at = |k: (f64, f64), t: f64, v: f64| (k.0 - t).abs() < 0.01 && (k.1 - v).abs() < 1.0;
|
||||
assert!(at(keys[0], 0.0, 0.0) && at(keys[2], 2.0, 100.0), "the other keys stay: {keys:?}");
|
||||
assert!(at(keys[1], 1.0, 75.0), "a key at 1 s at 75%: {keys:?}");
|
||||
}
|
||||
|
||||
/// The bars show while the selector, its animator or a property in the animator is selected,
|
||||
/// and only with the Selection tool and layer controls on.
|
||||
#[test]
|
||||
fn bars_show_only_for_a_selected_selector_or_animator() {
|
||||
let (s, ids) = setup();
|
||||
let h = harness(s);
|
||||
assert!(handle(&h, ids.end).is_none(), "only the layer is selected");
|
||||
let (mut s, ids) = setup();
|
||||
let position = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), position)];
|
||||
let h = harness(s);
|
||||
assert!(handle(&h, ids.end).is_none(), "a property outside the animator is selected");
|
||||
let (mut s, ids) = setup();
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.color)];
|
||||
let mut h = harness(s);
|
||||
assert!(handle(&h, ids.end).is_some(), "Fill Color, in the animator, is selected");
|
||||
h.state_mut().ui.viewer.show_layer_controls = false;
|
||||
h.run_steps(3);
|
||||
assert!(handle(&h, ids.end).is_none(), "View ▸ Show Layer Controls off hides them");
|
||||
h.state_mut().ui.viewer.show_layer_controls = true;
|
||||
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Pen;
|
||||
h.run_steps(3);
|
||||
assert!(handle(&h, ids.end).is_none(), "only the Selection tool drags them");
|
||||
}
|
||||
|
||||
/// Offset 25%: the Start bar sits two characters in, where the range begins, and dragging it to
|
||||
/// the boundary after the fourth character writes Start = 50% − Offset, leaving Offset alone.
|
||||
#[test]
|
||||
fn bars_include_the_offset_and_a_drag_leaves_it() {
|
||||
let (mut s, ids) = setup();
|
||||
let offset = animator(&s, ids.layer).sub("selectors").unwrap().groups().next().unwrap().get("offset").unwrap().uid;
|
||||
s.execute("prop.set", json!({"layer": ids.layer, "prop": offset, "value": 25.0})).unwrap();
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.animator)];
|
||||
let mut h = harness(s);
|
||||
let at = handle(&h, ids.start).expect("a Start bar");
|
||||
assert!((at.x - caret_x(&h, &ids, 2)).abs() < 12.0, "Start 0% + Offset 25%: after the second character");
|
||||
let dx = caret_x(&h, &ids, 4) - caret_x(&h, &ids, 2);
|
||||
drag(&mut h, at, at + vec2(dx, 0.0));
|
||||
assert!((value(&h, &ids, ids.start) - 25.0).abs() < 1.0, "Start {}", value(&h, &ids, ids.start));
|
||||
assert_eq!(value(&h, &ids, offset), 25.0, "Offset stays");
|
||||
}
|
||||
|
||||
/// Small text: Start at 0% sits within the reach of the bounding box's left-side scale handle. A
|
||||
/// press on the handle still scales the layer and leaves Start alone.
|
||||
#[test]
|
||||
fn a_bar_on_a_box_handle_leaves_it_the_press() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let l = s.execute("layer.newText", json!({"text": "abcdefgh", "size": 12})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"layer": l, "property": "fillColor"})).unwrap();
|
||||
let a = animator(&s, l);
|
||||
let start = a.sub("selectors").unwrap().groups().next().unwrap().get("start").unwrap().uid;
|
||||
s.state.selected_layers = vec![LayerId(l)];
|
||||
s.state.selected_props = vec![(LayerId(l), a.uid)];
|
||||
let mut h = harness(s);
|
||||
let bar = handle(&h, start).expect("a Start bar");
|
||||
let at = (0..8)
|
||||
.filter_map(|i| h.state().auto.find(&format!("viewer.handle.{l}.{i}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)))
|
||||
.min_by(|p, q| (*p - bar).length().total_cmp(&(*q - bar).length()))
|
||||
.expect("the box's handles");
|
||||
assert!((bar - at).length() < 9.0, "the fixture puts Start within the handle's reach: {bar:?} vs {at:?}");
|
||||
let scale = |h: &Harness<'_, EffectcraftApp>| {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/scale").unwrap().value.clone()
|
||||
};
|
||||
let before = scale(&h);
|
||||
drag(&mut h, at, at - vec2(40.0, 0.0));
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
assert_eq!(layer.props.find(start).unwrap().value.as_f64(), 0.0, "Start stays");
|
||||
assert_ne!(scale(&h), before, "the handle scaled the layer");
|
||||
}
|
||||
@@ -146,6 +146,15 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in
|
||||
the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group
|
||||
converts to the group's space, and the handles show only for a selected gradient.
|
||||
- Regression evidence for [#1011](https://github.com/storytold/effectcraft/issues/1011): the
|
||||
`ui_text_range_handles` input tests, checked headlessly on macOS, drag a text Range Selector's
|
||||
End bar in the Composition viewer: one undo step, Start and the layer stay, and on an animated
|
||||
End a keyframe at the current time. The bars show only for a selected selector or a selected
|
||||
animator (or a property in it), with the Selection tool and layer controls on. Unit tests in
|
||||
`text` place the bars on unit boundaries (words and Characters Excluding Spaces skip spaces,
|
||||
inside a ligature, on the second line, right to left) and invert them; `render` covers the
|
||||
value a drag writes (less the Offset, whole units in Index). Not done: dragging Offset, bars on
|
||||
vertical text and on text on a path.
|
||||
- Regression evidence for the toolbar's gradient swatches (#612, reported on Discord): the
|
||||
`ui_toolbar_gradient` input tests, checked headlessly on Windows, click a Linear Fill swatch and
|
||||
a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill
|
||||
|
||||
Reference in New Issue
Block a user