From b2ed7a66e44f9cd43695e5f16ee1133b44fa87ac Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 9 Aug 2026 05:22:30 +0800 Subject: [PATCH] feat(gpui_widgets): add curve editor with bezier handle dragging CurveEditor edits a normalized x->y keyframe curve: cubic bezier segments with per-point in/out control handles. Points are dragged with x clamped to keep the list sorted; handles drag as offsets; double-click inserts a new point at the cursor. Every gesture emits CurveEditorEvent requests while the widget keeps a local working copy for fluid dragging. Pure geometry (bezier evaluation, sampling via binary search, polyline approximation, hit tests) is unit-tested; interaction tests cover point dragging and adding. 65 tests pass. --- crates/gpui_widgets/src/curve_editor/curve.rs | 300 ++++++++++ crates/gpui_widgets/src/curve_editor/mod.rs | 541 ++++++++++++++++++ crates/gpui_widgets/src/lib.rs | 1 + 3 files changed, 842 insertions(+) create mode 100644 crates/gpui_widgets/src/curve_editor/curve.rs create mode 100644 crates/gpui_widgets/src/curve_editor/mod.rs diff --git a/crates/gpui_widgets/src/curve_editor/curve.rs b/crates/gpui_widgets/src/curve_editor/curve.rs new file mode 100644 index 0000000000..8804ff6748 --- /dev/null +++ b/crates/gpui_widgets/src/curve_editor/curve.rs @@ -0,0 +1,300 @@ +//! Pure curve geometry for the [`CurveEditor`](super::CurveEditor): cubic +//! bezier keyframe curves with in/out control handles. No gpui dependency. + +/// A 2D point in normalized curve space (`x` and `y` in `0..1`). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CurveVec2 { + /// Horizontal position. + pub x: f64, + /// Vertical position. + pub y: f64, +} + +impl CurveVec2 { + /// Create a vector. + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// Which control handle of a point is being edited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandleSide { + /// The handle leading into the point (from the previous point). + In, + /// The handle leaving the point (toward the next point). + Out, +} + +/// A keyframe point with optional bezier control handles (offsets from the +/// point, in normalized units). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CurvePoint { + /// Input position, `0..1`. + pub x: f64, + /// Output value, `0..1`. + pub y: f64, + /// Control point offset for the incoming segment. + pub handle_in: Option, + /// Control point offset for the outgoing segment. + pub handle_out: Option, +} + +impl CurvePoint { + /// Create a point with no handles (linear segments). + pub const fn new(x: f64, y: f64) -> Self { + Self { + x, + y, + handle_in: None, + handle_out: None, + } + } + + /// Create a point with both handles set to `offset`. + pub const fn with_handles(x: f64, y: f64, offset: CurveVec2) -> Self { + Self { + x, + y, + handle_in: Some(offset), + handle_out: Some(offset), + } + } +} + +/// Evaluate a cubic bezier at `t` in `0..1`. +pub fn cubic_bezier(p0: CurveVec2, c1: CurveVec2, c2: CurveVec2, p1: CurveVec2, t: f64) -> CurveVec2 { + let u = 1.0 - t; + CurveVec2::new( + u * u * u * p0.x + 3.0 * u * u * t * c1.x + 3.0 * u * t * t * c2.x + t * t * t * p1.x, + u * u * u * p0.y + 3.0 * u * u * t * c1.y + 3.0 * u * t * t * c2.y + t * t * t * p1.y, + ) +} + +/// The control points of the segment from `p0` to `p1` (linear when handles +/// are absent). +pub fn segment_controls(p0: &CurvePoint, p1: &CurvePoint) -> (CurveVec2, CurveVec2) { + let c1 = match p0.handle_out { + Some(h) => CurveVec2::new(p0.x + h.x, p0.y + h.y), + None => CurveVec2::new((p0.x + p1.x) / 2.0, p0.y), + }; + let c2 = match p1.handle_in { + Some(h) => CurveVec2::new(p1.x + h.x, p1.y + h.y), + None => CurveVec2::new((p0.x + p1.x) / 2.0, p1.y), + }; + (c1, c2) +} + +/// Sample the curve at `x`, returning the output value. `x` is clamped to +/// the point range; values before the first (after the last) point clamp to +/// the first (last) point's output. +pub fn sample_curve(points: &[CurvePoint], x: f64) -> f64 { + if points.is_empty() { + return 0.0; + } + if points.len() == 1 { + return points[0].y; + } + let x = x.clamp(points[0].x, points[points.len() - 1].x); + let index = points + .windows(2) + .position(|w| x >= w[0].x && x <= w[1].x) + .unwrap_or(points.len() - 2); + let p0 = &points[index]; + let p1 = &points[index + 1]; + let (c1, c2) = segment_controls(p0, p1); + sample_segment( + CurveVec2::new(p0.x, p0.y), + c1, + c2, + CurveVec2::new(p1.x, p1.y), + x, + ) +} + +/// Sample one segment for the `y` at a given `x`, by finding the `t` whose +/// bezier x coordinate matches (binary search, since bezier x is monotonic +/// for well-formed curves). +fn sample_segment(p0: CurveVec2, c1: CurveVec2, c2: CurveVec2, p1: CurveVec2, x: f64) -> f64 { + let mut lo = 0.0; + let mut hi = 1.0; + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + let px = cubic_bezier(p0, c1, c2, p1, mid).x; + if px < x { + lo = mid; + } else { + hi = mid; + } + } + let t = (lo + hi) / 2.0; + cubic_bezier(p0, c1, c2, p1, t).y +} + +/// Approximate the whole curve as a polyline (for painting). Each segment is +/// sampled `samples` times. +pub fn polyline(points: &[CurvePoint], samples: usize) -> Vec { + let mut out = Vec::new(); + if points.is_empty() { + return out; + } + out.push(CurveVec2::new(points[0].x, points[0].y)); + for window in points.windows(2) { + let (p0, p1) = (&window[0], &window[1]); + let (c1, c2) = segment_controls(p0, p1); + let p0v = CurveVec2::new(p0.x, p0.y); + let p1v = CurveVec2::new(p1.x, p1.y); + for i in 1..=samples { + let t = i as f64 / samples as f64; + out.push(cubic_bezier(p0v, c1, c2, p1v, t)); + } + } + out +} + +/// Find the point closest to `pos` within `threshold` (normalized units). +pub fn hit_test_point(points: &[CurvePoint], pos: CurveVec2, threshold: f64) -> Option { + let mut best = None; + let mut best_dist = threshold; + for (index, point) in points.iter().enumerate() { + let dx = point.x - pos.x; + let dy = point.y - pos.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist <= best_dist { + best_dist = dist; + best = Some(index); + } + } + best +} + +/// Find the handle closest to `pos` within `threshold`, preferring handles +/// over points when both are within reach. +pub fn hit_test_handle( + points: &[CurvePoint], + pos: CurveVec2, + threshold: f64, +) -> Option<(usize, HandleSide)> { + let mut best = None; + let mut best_dist = threshold; + for (index, point) in points.iter().enumerate() { + for (side, handle) in [ + (HandleSide::In, point.handle_in), + (HandleSide::Out, point.handle_out), + ] { + if let Some(h) = handle { + let hp = CurveVec2::new(point.x + h.x, point.y + h.y); + let dx = hp.x - pos.x; + let dy = hp.y - pos.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist <= best_dist { + best_dist = dist; + best = Some((index, side)); + } + } + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 + } + + #[test] + fn bezier_endpoints() { + let p0 = CurveVec2::new(0.0, 0.0); + let p1 = CurveVec2::new(1.0, 1.0); + assert_eq!(cubic_bezier(p0, p0, p1, p1, 0.0), p0); + assert_eq!(cubic_bezier(p0, p0, p1, p1, 1.0), p1); + // A straight-line bezier at t=0.5 is the midpoint. + let mid = cubic_bezier(p0, p0, p1, p1, 0.5); + assert!(approx(mid.x, 0.5) && approx(mid.y, 0.5)); + } + + #[test] + fn linear_curve_samples_exactly() { + let points = vec![CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)]; + assert!(approx(sample_curve(&points, 0.0), 0.0)); + assert!(approx(sample_curve(&points, 0.5), 0.5)); + assert!(approx(sample_curve(&points, 1.0), 1.0)); + // Clamps outside the range. + assert!(approx(sample_curve(&points, 2.0), 1.0)); + assert!(approx(sample_curve(&points, -1.0), 0.0)); + } + + #[test] + fn stepped_curve_clamps_to_segments() { + let points = vec![ + CurvePoint::new(0.0, 0.0), + CurvePoint::new(0.5, 0.0), + CurvePoint::new(1.0, 1.0), + ]; + assert!(approx(sample_curve(&points, 0.25), 0.0)); + assert!(approx(sample_curve(&points, 0.75), 0.5)); + } + + #[test] + fn single_point_is_constant() { + let points = vec![CurvePoint::new(0.5, 0.7)]; + assert!(approx(sample_curve(&points, 0.0), 0.7)); + assert!(approx(sample_curve(&points, 0.9), 0.7)); + } + + #[test] + fn bezier_handles_bend_the_curve() { + // A curve whose outgoing handle pushes straight up at the start must + // start with output above the linear interpolation. + let points = vec![ + CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 1.0)), + CurvePoint::new(1.0, 1.0), + ]; + let linear = sample_curve(&[CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)], 0.25); + let bent = sample_curve(&points, 0.25); + assert!(bent > linear, "bent={bent} linear={linear}"); + } + + #[test] + fn polyline_has_expected_length() { + let points = vec![CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)]; + let line = polyline(&points, 8); + assert_eq!(line.len(), 9); + assert_eq!(line.first().unwrap(), &CurveVec2::new(0.0, 0.0)); + assert_eq!(line.last().unwrap(), &CurveVec2::new(1.0, 1.0)); + assert_eq!(polyline(&[], 8).len(), 0); + } + + #[test] + fn hit_test_finds_nearest_point() { + let points = vec![ + CurvePoint::new(0.1, 0.1), + CurvePoint::new(0.5, 0.5), + CurvePoint::new(0.9, 0.9), + ]; + assert_eq!(hit_test_point(&points, CurveVec2::new(0.52, 0.52), 0.1), Some(1)); + assert_eq!(hit_test_point(&points, CurveVec2::new(0.1, 0.1), 0.1), Some(0)); + // Beyond the threshold. + assert_eq!(hit_test_point(&points, CurveVec2::new(0.3, 0.3), 0.05), None); + } + + #[test] + fn hit_test_finds_handle() { + let points = vec![CurvePoint { + x: 0.2, + y: 0.5, + handle_in: Some(CurveVec2::new(-0.1, -0.1)), + handle_out: Some(CurveVec2::new(0.1, 0.1)), + }]; + // The outgoing handle endpoint sits at (0.3, 0.6). + let hit = hit_test_handle(&points, CurveVec2::new(0.31, 0.61), 0.05); + assert_eq!(hit, Some((0, HandleSide::Out))); + // The incoming handle endpoint sits at (0.1, 0.4). + let hit = hit_test_handle(&points, CurveVec2::new(0.09, 0.39), 0.05); + assert_eq!(hit, Some((0, HandleSide::In))); + assert_eq!(hit_test_handle(&points, CurveVec2::new(0.9, 0.9), 0.05), None); + } +} diff --git a/crates/gpui_widgets/src/curve_editor/mod.rs b/crates/gpui_widgets/src/curve_editor/mod.rs new file mode 100644 index 0000000000..7c5449b732 --- /dev/null +++ b/crates/gpui_widgets/src/curve_editor/mod.rs @@ -0,0 +1,541 @@ +//! A keyframe curve editor: edit a cubic-bezier curve by dragging points and +//! their control handles. +//! +//! The curve is a normalized `x in 0..1` -> `y in 0..1` mapping (e.g. a time +//! remap). The widget keeps a local working copy of the points for fluid +//! dragging and emits [`CurveEditorEvent`] requests; the host applies them +//! through its model and calls [`CurveEditor::set_points`] to reconcile. +//! Pure geometry lives in [`curve`] and is unit-tested. + +mod curve; + +use std::sync::{Arc, RwLock}; + +use gpui::{ + App, Bounds, ClickEvent, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle, + Focusable, Hsla, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Render, Window, + canvas, colors::DefaultColors, div, fill, point, prelude::*, px, quad, size, +}; +use gpui::{BorderStyle, Corners, Edges, PathBuilder}; + +pub use curve::{CurvePoint, CurveVec2, HandleSide, hit_test_handle, hit_test_point, sample_curve}; + +/// The default editor height. +const EDITOR_HEIGHT: f32 = 120.0; +/// The paint threshold (normalized units) for grabbing a point or handle. +const HIT_THRESHOLD: f64 = 0.06; + +/// A request emitted by a curve editor. +#[derive(Debug, Clone, PartialEq)] +pub enum CurveEditorEvent { + /// A keyframe point was dragged to a new position. + PointMoved { + /// The control's stable id. + control: usize, + /// The point's index in the (sorted) point list. + index: usize, + /// The point's new position/handles. + point: CurvePoint, + }, + /// A bezier control handle was dragged. + HandleMoved { + /// The control's stable id. + control: usize, + /// The owning point's index. + index: usize, + /// Which handle was moved. + side: HandleSide, + /// The handle's new offset from the point (normalized). + handle: CurveVec2, + }, + /// A new keyframe point was added (double-click on the canvas). + PointAdded { + /// The control's stable id. + control: usize, + /// The index the point was inserted at. + index: usize, + /// The new point. + point: CurvePoint, + }, +} + +/// What a drag gesture is editing. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DragTarget { + None, + Point(usize), + Handle { index: usize, side: HandleSide }, +} + +/// Transient payload carried by an in-flight drag. +#[derive(Clone, Copy, Debug)] +struct CurveDrag { + target: DragTarget, +} + +/// Invisible ghost view for drags. +#[derive(Clone, Copy, Debug)] +struct CurveGhost; + +impl Render for CurveGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().w(px(0.0)).h(px(0.0)) + } +} + +/// A keyframe curve editor. +pub struct CurveEditor { + control: usize, + points: Vec, + focus_handle: FocusHandle, + /// Canvas bounds, refreshed each frame for position conversion. + bounds: Bounds, + /// Hit target recorded on mouse-down, consumed when the drag starts. + pending_drag: Option, +} + +impl CurveEditor { + /// Create an editor for `control` over `points` (already sorted by x). + pub fn new( + control: usize, + points: Vec, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + control, + points, + focus_handle: cx.focus_handle(), + bounds: Bounds::default(), + pending_drag: None, + } + } + + /// The current working copy of the curve. + pub fn points(&self) -> &[CurvePoint] { + &self.points + } + + /// Apply the host's reconciled curve and repaint. + pub fn set_points(&mut self, points: Vec, cx: &mut Context) { + self.points = points; + cx.notify(); + } + + /// Convert a window position into normalized curve coordinates. + fn normalize(&self, position: Point) -> CurveVec2 { + let w = f32::from(self.bounds.size.width); + let h = f32::from(self.bounds.size.height); + if w <= 0.0 || h <= 0.0 { + return CurveVec2::new(0.5, 0.5); + } + CurveVec2::new( + ((f32::from(position.x) - f32::from(self.bounds.left())) / w).clamp(0.0, 1.0) as f64, + 1.0 - ((f32::from(position.y) - f32::from(self.bounds.top())) / h).clamp(0.0, 1.0) + as f64, + ) + } + + fn hit_test(&self, position: Point) -> DragTarget { + let pos = self.normalize(position); + if let Some((index, side)) = hit_test_handle(&self.points, pos, HIT_THRESHOLD) { + return DragTarget::Handle { index, side }; + } + if let Some(index) = hit_test_point(&self.points, pos, HIT_THRESHOLD) { + return DragTarget::Point(index); + } + DragTarget::None + } + + /// Clamp a point's x so the list stays sorted (points cannot pass each + /// other), while y is clamped to the unit range. + fn move_point(&mut self, index: usize, pos: CurveVec2, cx: &mut Context) { + let (prev_x, next_x) = if self.points.len() == 1 { + (0.0, 1.0) + } else if index == 0 { + (0.0, self.points[1].x) + } else if index == self.points.len() - 1 { + (self.points[index - 1].x, 1.0) + } else { + (self.points[index - 1].x, self.points[index + 1].x) + }; + let min_x = (prev_x + 0.001).min(1.0); + let max_x = (next_x - 0.001).max(0.0); + let point = self.points.get_mut(index).expect("point index in range"); + point.x = pos.x.clamp(min_x, max_x); + point.y = pos.y.clamp(0.0, 1.0); + let moved = *point; + cx.emit(CurveEditorEvent::PointMoved { + control: self.control, + index, + point: moved, + }); + cx.notify(); + } + + fn move_handle( + &mut self, + index: usize, + side: HandleSide, + pos: CurveVec2, + cx: &mut Context, + ) { + let Some(point) = self.points.get_mut(index) else { + return; + }; + let offset = CurveVec2::new(pos.x - point.x, pos.y - point.y); + match side { + HandleSide::In => point.handle_in = Some(offset), + HandleSide::Out => point.handle_out = Some(offset), + } + cx.emit(CurveEditorEvent::HandleMoved { + control: self.control, + index, + side, + handle: offset, + }); + cx.notify(); + } + + /// Insert a new point at `pos` (double-click), keeping the list sorted. + fn add_point(&mut self, pos: CurveVec2, cx: &mut Context) { + let x = pos.x.clamp(0.0, 1.0); + let y = pos.y.clamp(0.0, 1.0); + let insert_at = self.points.partition_point(|p| p.x < x); + self.points.insert(insert_at, CurvePoint::new(x, y)); + cx.emit(CurveEditorEvent::PointAdded { + control: self.control, + index: insert_at, + point: CurvePoint::new(x, y), + }); + cx.notify(); + } +} + +impl EventEmitter for CurveEditor {} + +impl Focusable for CurveEditor { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CurveEditor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let entity = cx.entity(); + let canvas_entity = entity.clone(); + let points = self.points.clone(); + let control = self.control; + + div() + .id(ElementId::named_usize("gpui-widgets-curve", control)) + .h(px(EDITOR_HEIGHT)) + .rounded_md() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, _cx| { + this.pending_drag = Some(this.hit_test(event.position)); + }), + ) + .on_drag( + Arc::new(RwLock::new(CurveDrag { + target: DragTarget::None, + })), + move |drag, offset, window, cx| curve_ghost(drag, offset, window, cx, entity.clone()), + ) + .on_drag_move( + cx.listener( + |this, event: &DragMoveEvent>>, _window, cx| { + let drag = event.drag(cx).clone(); + let target = drag.read().unwrap().target; + let pos = this.normalize(event.event.position); + match target { + DragTarget::Point(index) => this.move_point(index, pos, cx), + DragTarget::Handle { index, side } => { + this.move_handle(index, side, pos, cx); + } + DragTarget::None => {} + } + }, + ), + ) + .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| { + if event.click_count() >= 2 { + this.add_point(this.normalize(event.position()), cx); + } + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + if matches!(event.keystroke.key.as_str(), "escape") { + this.pending_drag = None; + } + cx.notify(); + })) + .child( + canvas( + move |bounds, _window, cx| { + canvas_entity.update(cx, |this, _| this.bounds = bounds); + bounds + }, + move |bounds, content, window, cx| { + paint_curve(bounds, content, &points, &colors, window, cx); + }, + ) + .size_full(), + ) + } +} + +/// Initialize an in-flight drag with the hit target recorded at mouse-down. +fn curve_ghost( + drag: &Arc>, + _offset: Point, + _window: &mut Window, + cx: &mut App, + entity: Entity, +) -> Entity { + entity.update(cx, |this, _| { + let target = this.pending_drag.take().unwrap_or(DragTarget::None); + if let Ok(mut drag) = drag.write() { + drag.target = target; + } + }); + cx.new(|_| CurveGhost) +} + +fn paint_curve( + bounds: Bounds, + _content: Bounds, + points: &[CurvePoint], + colors: &gpui::colors::Colors, + window: &mut Window, + _cx: &mut App, +) { + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + if width <= 0.0 || height <= 0.0 { + return; + } + let to_px = |v: CurveVec2| { + point( + bounds.left() + px((v.x as f32) * width), + bounds.top() + px((1.0 - v.y as f32) * height), + ) + }; + + // Subtle grid lines. + let grid = Hsla::from(colors.border).opacity(0.35); + for i in 1..4 { + let fx = i as f32 / 4.0; + window.paint_quad(fill( + Bounds::new( + point(bounds.left() + px(fx * width), bounds.top()), + size(px(1.0), bounds.size.height), + ), + grid, + )); + window.paint_quad(fill( + Bounds::new( + point(bounds.left(), bounds.top() + px(fx * height)), + size(bounds.size.width, px(1.0)), + ), + grid, + )); + } + + // The curve polyline. + let line = curve::polyline(points, 16); + if line.len() >= 2 { + let mut path = PathBuilder::stroke(px(2.0)); + let mut iter = line.iter(); + if let Some(first) = iter.next() { + path.move_to(to_px(*first)); + } + for v in iter { + path.line_to(to_px(*v)); + } + if let Ok(path) = path.build() { + window.paint_path(path, Hsla::from(colors.selected)); + } + } + + // Points and handles. + let point_color = Hsla::from(colors.text); + let handle_color = Hsla::from(colors.disabled); + for pt in points { + let p = to_px(CurveVec2::new(pt.x, pt.y)); + for handle in [pt.handle_in, pt.handle_out] { + if let Some(h) = handle { + let hp = to_px(CurveVec2::new(pt.x + h.x, pt.y + h.y)); + let mut line = PathBuilder::stroke(px(1.0)); + line.move_to(p); + line.line_to(hp); + if let Ok(path) = line.build() { + window.paint_path(path, handle_color); + } + let dot = Bounds::new( + point(hp.x - px(3.0), hp.y - px(3.0)), + size(px(6.0), px(6.0)), + ); + window.paint_quad(quad( + dot, + Corners::all(px(3.0)), + handle_color, + Edges::all(px(0.0)), + handle_color, + BorderStyle::Solid, + )); + } + } + let r = px(5.0); + let circle = Bounds::new(point(p.x - r, p.y - r), size(r * 2.0, r * 2.0)); + window.paint_quad(quad( + circle, + Corners::all(r), + point_color, + Edges::all(px(1.5)), + Hsla::from(colors.background), + BorderStyle::Solid, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Modifiers, TestAppContext, VisualTestContext}; + + fn demo_points() -> Vec { + vec![ + CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 0.5)), + CurvePoint::with_handles(1.0, 1.0, CurveVec2::new(0.0, -0.5)), + ] + } + + #[gpui::test] + async fn dragging_a_point_emits_point_moved(cx: &mut TestAppContext) { + struct Host { + editor: Entity, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.editor.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(400.0), px(200.0)), |window, cx| { + let editor = cx.new(|cx| CurveEditor::new(1, demo_points(), window, cx)); + let host = Host { + editor, + events: Vec::new(), + }; + cx.subscribe( + &host.editor, + |host: &mut Host, + _e: Entity, + event: &CurveEditorEvent, + _cx: &mut Context| { + host.events.push(event.clone()); + }, + ) + .detach(); + host + }); + cx.run_until_parked(); + let host = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + // The curve editor spans the full window width, 120px tall. The + // first point is at normalized (0,0) -> bottom-left of the canvas. + let start = point(px(5.0), px(115.0)); + let drag_to = point(px(5.0), px(60.0)); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(point(px(5.0), px(105.0)), MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_move(drag_to, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(drag_to, MouseButton::Left, Modifiers::none()); + cx.run_until_parked(); + + let (points, moved) = cx.read(|app| { + let host = host.read(app); + let points = host.editor.read(app).points().to_vec(); + let moved = host.events.iter().any(|e| { + matches!(e, CurveEditorEvent::PointMoved { index: 0, .. }) + }); + (points, moved) + }); + assert!(moved, "expected a PointMoved event for point 0"); + assert!(points[0].y > 0.1, "point should have moved up: {:?}", points[0]); + } + + #[gpui::test] + async fn double_click_adds_point(cx: &mut TestAppContext) { + struct Host { + editor: Entity, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.editor.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(400.0), px(200.0)), |window, cx| { + let editor = cx.new(|cx| CurveEditor::new(1, demo_points(), window, cx)); + let host = Host { + editor, + events: Vec::new(), + }; + cx.subscribe( + &host.editor, + |host: &mut Host, + _e: Entity, + event: &CurveEditorEvent, + _cx: &mut Context| { + host.events.push(event.clone()); + }, + ) + .detach(); + host + }); + cx.run_until_parked(); + let host = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + // Double-click in the middle of the canvas (x=200, y=100). + let pos = point(px(200.0), px(100.0)); + let modifiers = Modifiers::none(); + cx.simulate_event(MouseDownEvent { + position: pos, + modifiers, + button: MouseButton::Left, + click_count: 2, + first_mouse: false, + }); + cx.simulate_event(gpui::MouseUpEvent { + position: pos, + modifiers, + button: MouseButton::Left, + click_count: 2, + }); + cx.run_until_parked(); + + let (count, added) = cx.read(|app| { + let host = host.read(app); + ( + host.editor.read(app).points().len(), + host.events + .iter() + .any(|e| matches!(e, CurveEditorEvent::PointAdded { .. })), + ) + }); + assert_eq!(count, 3); + assert!(added); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index 8d14a31fc7..7928290f61 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -20,6 +20,7 @@ pub mod checkbox; pub mod color; pub mod combo_box; +pub mod curve_editor; pub mod keyable; pub mod radio_group; pub mod slider;