diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 55dfd630..fbb0feb2 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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 [f64; 3]> = match sel.match_id.as_str() { "rangeSelector" => { let r = range_params(ctx, layer, sel, n); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 547c02d4..020e0851 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -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"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e8ba9d95..18716752 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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, + /// Whether each character is white space (for the selectors' Based On). + pub space_of_char: Vec, 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 { + 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 = [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); + } } diff --git a/crates/ui-egui/src/panels/gradient_handles.rs b/crates/ui-egui/src/panels/gradient_handles.rs index bfe87a47..e855a49e 100644 --- a/crates/ui-egui/src/panels/gradient_handles.rs +++ b/crates/ui-egui/src/panels/gradient_handles.rs @@ -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)))) } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c3..cedea209 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/text_range_handles.rs b/crates/ui-egui/src/panels/text_range_handles.rs new file mode 100644 index 00000000..bb5f01e3 --- /dev/null +++ b/crates/ui-egui/src/panels/text_range_handles.rs @@ -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.` (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 = 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::(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)); +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 51bab7fd..4f71a679 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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 { diff --git a/crates/ui-egui/tests/ui_text_range_handles.rs b/crates/ui-egui/tests/ui_text_range_handles.rs new file mode 100644 index 00000000..1d048be5 --- /dev/null +++ b/crates/ui-egui/tests/ui_text_range_handles.rs @@ -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 { + 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"); +} diff --git a/docs/gaps.md b/docs/gaps.md index a20e779c..4f160b13 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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