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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user