Files
Ely-GPUI-Components/src/forms/signature.rs
T

284 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::rc::Rc;
use gpui::{
App, Bounds, Corners, DragMoveEvent, ElementId, EmptyView, EntityId, Hsla, InteractiveElement,
IntoElement, MouseButton, ParentElement, Path, PathBuilder, Pixels, Point, RenderOnce,
StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, prelude::*, size,
};
use crate::{
buttons::{Button, ButtonVariant},
layout::seeded::use_seeded,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
/// A pen stroke: points from the pad's top left corner.
pub type Stroke = Vec<Point<Pixels>>;
type OnStrokes = Rc<dyn Fn(&[Stroke], &mut Window, &mut App)>;
type Lift = Rc<dyn Fn(Point<Pixels>, &mut Window, &mut App)>;
struct Pen {
owner: EntityId,
}
/// Where the pad sits, and the stroke under the pen.
#[derive(Default)]
struct Drawing {
bounds: Bounds<Pixels>,
stroke: Stroke,
}
impl Drawing {
/// Adds `at` to the stroke under the pen, unless the pen has not moved.
fn reach(&mut self, at: Point<Pixels>) {
let at = local(self.bounds, at);
if self.stroke.last() != Some(&at) {
self.stroke.push(at);
}
}
}
/// `at` from the pad's corner, kept inside the pad.
fn local(bounds: Bounds<Pixels>, at: Point<Pixels>) -> Point<Pixels> {
point(
(at.x - bounds.left())
.max(Pixels::ZERO)
.min(bounds.size.width),
(at.y - bounds.top())
.max(Pixels::ZERO)
.min(bounds.size.height),
)
}
/// A line through `points`, bent through their midpoints so it reads smooth.
fn ink(points: &[Point<Pixels>], origin: Point<Pixels>, width: Pixels) -> Option<Path<Pixels>> {
let [first, .., last] = points else {
return None;
};
let at = |p: Point<Pixels>| point(origin.x + p.x, origin.y + p.y);
let mut path = PathBuilder::stroke(width);
path.move_to(at(*first));
for pair in points.windows(2) {
let middle = point((pair[0].x + pair[1].x) / 2.0, (pair[0].y + pair[1].y) / 2.0);
path.curve_to(at(middle), at(pair[0]));
}
path.line_to(at(*last));
Some(path.build().expect("a stroke is a simple open path"))
}
fn paint(
strokes: &[Stroke],
bounds: Bounds<Pixels>,
width: Pixels,
color: Hsla,
window: &mut Window,
) {
let dot = |at: Point<Pixels>| {
let center = point(bounds.left() + at.x, bounds.top() + at.y);
fill(Bounds::centered_at(center, size(width, width)), color)
.corner_radii(Corners::all(width / 2.0))
};
for stroke in strokes {
if let Some(path) = ink(stroke, bounds.origin, width) {
window.paint_path(path, color);
}
for end in [stroke.first(), stroke.last()].into_iter().flatten() {
window.paint_quad(dot(*end));
}
}
}
/// A pad to sign with the pointer. A stroke lands when the pen lifts; Clear wipes them all.
#[derive(IntoElement)]
pub struct SignaturePad {
id: ElementId,
strokes: Vec<Stroke>,
on_change: Option<OnStrokes>,
}
impl SignaturePad {
pub fn new(id: impl Into<ElementId>, strokes: impl IntoIterator<Item = Stroke>) -> Self {
Self {
id: id.into(),
strokes: strokes.into_iter().collect(),
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(&[Stroke], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SignaturePad {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let strokes = use_seeded((self.id.clone(), "strokes"), self.strokes, window, cx);
let drawing =
window.use_keyed_state((self.id.clone(), "drawing"), cx, |_, _| Drawing::default());
let owner = drawing.entity_id();
let set: OnStrokes = {
let (id, strokes, on_change) = (self.id.clone(), strokes.clone(), self.on_change);
Rc::new(move |next, window, cx| {
log::info!("signature pad {id:?}: {} strokes", next.len());
strokes.update(cx, |strokes, cx| {
strokes.value = next.to_vec();
cx.notify();
});
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
})
};
let lift: Lift = {
let (strokes, drawing, set) = (strokes.clone(), drawing.clone(), set.clone());
Rc::new(move |at, window, cx| {
let stroke = drawing.update(cx, |drawing, _| {
if !drawing.stroke.is_empty() {
drawing.reach(at);
}
std::mem::take(&mut drawing.stroke)
});
if stroke.is_empty() {
return;
}
let mut next = strokes.read(cx).value.clone();
next.push(stroke);
set(&next, window, cx);
})
};
let theme = cx.theme();
let colors = &theme.colors;
let (height, width) = theme.signature();
let width = width.to_pixels(window.rem_size());
let empty = strokes.read(cx).value.is_empty();
let mut shown = strokes.read(cx).value.clone();
shown.push(drawing.read(cx).stroke.clone());
let (measure, press, start, pen) =
(drawing.clone(), drawing.clone(), drawing.clone(), drawing);
let (up, out) = (lift.clone(), lift);
let color = colors.fg;
let pad = div()
.id(self.id.clone())
.relative()
.w_full()
.h(height)
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border_strong)
.bg(colors.surface)
.cursor_crosshair()
.child(
div()
.absolute()
.left_6()
.right_6()
.bottom_8()
.flex()
.items_end()
.gap_2()
.pb_1()
.border_b_1()
.border_dashed()
.border_color(colors.border_strong)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child("×")
.when(empty, |line| line.child("Sign here")),
)
.child(
canvas(
move |bounds, _, cx| {
if measure.read(cx).bounds != bounds {
measure.update(cx, |drawing, _| drawing.bounds = bounds);
}
},
move |bounds, _, window, _| paint(&shown, bounds, width, color, window),
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
press.update(cx, |drawing, cx| {
drawing.stroke = vec![local(drawing.bounds, event.position)];
cx.notify();
})
})
.on_drag(Pen { owner }, move |_, _, window, cx| {
let at = window.mouse_position();
start.update(cx, |drawing, cx| {
drawing.reach(at);
cx.notify();
});
cx.new(|_| EmptyView)
})
.on_drag_move(move |event: &DragMoveEvent<Pen>, _, cx| {
if event.drag(cx).owner != owner {
return;
}
pen.update(cx, |drawing, cx| {
drawing.reach(event.event.position);
cx.notify();
})
})
.on_mouse_up(MouseButton::Left, move |event, window, cx| {
up(event.position, window, cx)
})
.on_mouse_up_out(MouseButton::Left, move |event, window, cx| {
out(event.position, window, cx)
});
div()
.flex()
.flex_col()
.items_end()
.gap_1()
.w_full()
.child(pad)
.child(
Button::new((self.id, "clear"), "Clear")
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.disabled(empty)
.on_click(move |_, window, cx| set(&[], window, cx)),
)
}
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, point, px, size};
use super::{ink, local};
#[test]
fn points_stay_inside_the_pad() {
let bounds = Bounds::new(point(px(10.0), px(20.0)), size(px(100.0), px(50.0)));
assert_eq!(
local(bounds, point(px(30.0), px(40.0))),
point(px(20.0), px(20.0))
);
assert_eq!(
local(bounds, point(px(-5.0), px(90.0))),
point(px(0.0), px(50.0))
);
}
#[test]
fn a_stroke_needs_two_points_for_a_line() {
let origin = point(px(0.0), px(0.0));
assert!(ink(&[point(px(1.0), px(1.0))], origin, px(2.0)).is_none());
let line = [
point(px(1.0), px(1.0)),
point(px(9.0), px(4.0)),
point(px(12.0), px(9.0)),
];
assert!(ink(&line, origin, px(2.0)).is_some());
}
}