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.
This commit is contained in:
2026-08-09 05:22:30 +08:00
parent 25b3167162
commit b2ed7a66e4
3 changed files with 842 additions and 0 deletions
@@ -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<CurveVec2>,
/// Control point offset for the outgoing segment.
pub handle_out: Option<CurveVec2>,
}
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<CurveVec2> {
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<usize> {
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);
}
}
+541
View File
@@ -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<Self>) -> impl IntoElement {
div().w(px(0.0)).h(px(0.0))
}
}
/// A keyframe curve editor.
pub struct CurveEditor {
control: usize,
points: Vec<CurvePoint>,
focus_handle: FocusHandle,
/// Canvas bounds, refreshed each frame for position conversion.
bounds: Bounds<Pixels>,
/// Hit target recorded on mouse-down, consumed when the drag starts.
pending_drag: Option<DragTarget>,
}
impl CurveEditor {
/// Create an editor for `control` over `points` (already sorted by x).
pub fn new(
control: usize,
points: Vec<CurvePoint>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> 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<CurvePoint>, cx: &mut Context<Self>) {
self.points = points;
cx.notify();
}
/// Convert a window position into normalized curve coordinates.
fn normalize(&self, position: Point<Pixels>) -> 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<Pixels>) -> 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<Self>) {
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<Self>,
) {
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<Self>) {
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<CurveEditorEvent> 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<Self>) -> 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<Arc<RwLock<CurveDrag>>>, _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<RwLock<CurveDrag>>,
_offset: Point<Pixels>,
_window: &mut Window,
cx: &mut App,
entity: Entity<CurveEditor>,
) -> Entity<CurveGhost> {
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<Pixels>,
_content: Bounds<Pixels>,
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<CurvePoint> {
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<CurveEditor>,
events: Vec<CurveEditorEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<CurveEditor>,
event: &CurveEditorEvent,
_cx: &mut Context<Host>| {
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<CurveEditor>,
events: Vec<CurveEditorEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<CurveEditor>,
event: &CurveEditorEvent,
_cx: &mut Context<Host>| {
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);
}
}
+1
View File
@@ -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;