From 1bc5ff03dfac4c5d6fbd1386e292f24b1b8e9d24 Mon Sep 17 00:00:00 2001
From: han <83765419+han1548772930@users.noreply.github.com>
Date: Wed, 15 Oct 2025 15:27:59 +0800
Subject: [PATCH] story: Add interactive brush drawing canvas (#1370)
---------
Co-authored-by: hl
---
crates/story/examples/brush.rs | 466 +++++++++++++++++++++++++++++++++
1 file changed, 466 insertions(+)
create mode 100644 crates/story/examples/brush.rs
diff --git a/crates/story/examples/brush.rs b/crates/story/examples/brush.rs
new file mode 100644
index 00000000..415f105e
--- /dev/null
+++ b/crates/story/examples/brush.rs
@@ -0,0 +1,466 @@
+use std::rc::Rc;
+
+use gpui::{StyleRefinement, prelude::FluentBuilder, *};
+use gpui_component::{
+ ActiveTheme, Colorize as _, IconName, Sizable,
+ button::Button,
+ checkbox::Checkbox,
+ group_box::GroupBox,
+ h_flex,
+ slider::{Slider, SliderState},
+ v_flex,
+};
+use story::Assets;
+
+pub struct BrushStory {
+ focus_handle: gpui::FocusHandle,
+ brush_size: Entity,
+ brush_opacity: Entity,
+ brush_color: Hsla,
+ strokes: Rc>,
+ current_stroke: Option,
+ is_drawing: bool,
+ show_grid: bool,
+ canvas_bounds: Option>,
+}
+
+#[derive(Clone, Debug)]
+struct Stroke {
+ points: Vec>,
+ color: Hsla,
+ size: f32,
+}
+
+impl BrushStory {
+ fn new(_: &mut Window, cx: &mut Context) -> Self {
+ let brush_size = cx.new(|_| {
+ SliderState::new()
+ .min(1.)
+ .max(50.)
+ .default_value(5.)
+ .step(1.)
+ });
+
+ let brush_opacity = cx.new(|_| {
+ SliderState::new()
+ .min(0.1)
+ .max(1.0)
+ .default_value(1.0)
+ .step(0.05)
+ });
+
+ Self {
+ focus_handle: cx.focus_handle(),
+ brush_size,
+ brush_opacity,
+ brush_color: black(),
+ strokes: Rc::new(vec![]),
+ current_stroke: None,
+ is_drawing: false,
+ show_grid: false,
+ canvas_bounds: None,
+ }
+ }
+
+ fn handle_mouse_down(
+ &mut self,
+ event: &MouseDownEvent,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ if event.button == MouseButton::Left {
+ self.is_drawing = true;
+ let brush_size = self.brush_size.read(cx).value().start();
+ let brush_opacity = self.brush_opacity.read(cx).value().start();
+ let color = self.brush_color.opacity(brush_opacity);
+
+ let local_pos = if let Some(bounds) = self.canvas_bounds {
+ Point::new(
+ event.position.x - bounds.origin.x,
+ event.position.y - bounds.origin.y,
+ )
+ } else {
+ event.position
+ };
+
+ self.current_stroke = Some(Stroke {
+ points: vec![local_pos],
+ color,
+ size: brush_size,
+ });
+ cx.notify();
+ }
+ }
+
+ fn handle_mouse_move(
+ &mut self,
+ event: &MouseMoveEvent,
+ _: &mut Window,
+ cx: &mut Context,
+ ) {
+ if self.is_drawing {
+ if let Some(ref mut stroke) = self.current_stroke {
+ let local_pos = if let Some(bounds) = self.canvas_bounds {
+ Point::new(
+ event.position.x - bounds.origin.x,
+ event.position.y - bounds.origin.y,
+ )
+ } else {
+ event.position
+ };
+
+ let should_add = if let Some(last) = stroke.points.last() {
+ let dx_px = local_pos.x - last.x;
+ let dy_px = local_pos.y - last.y;
+
+ let dx_abs = if dx_px < px(0.0) {
+ px(0.0) - dx_px
+ } else {
+ dx_px
+ };
+ let dy_abs = if dy_px < px(0.0) {
+ px(0.0) - dy_px
+ } else {
+ dy_px
+ };
+ dx_abs >= px(1.0) || dy_abs >= px(1.0)
+ } else {
+ true
+ };
+
+ if should_add {
+ stroke.points.push(local_pos);
+ cx.notify();
+ }
+ }
+ }
+ }
+
+ fn handle_mouse_up(&mut self, _event: &MouseUpEvent, _: &mut Window, cx: &mut Context) {
+ if self.is_drawing {
+ self.is_drawing = false;
+ if let Some(stroke) = self.current_stroke.take() {
+ if stroke.points.len() > 1 {
+ let mut new_strokes = (*self.strokes).clone();
+ new_strokes.push(stroke);
+ self.strokes = Rc::new(new_strokes);
+ }
+ }
+ cx.notify();
+ }
+ }
+
+ fn clear_canvas(&mut self, cx: &mut Context) {
+ self.strokes = Rc::new(vec![]);
+ self.current_stroke = None;
+ self.is_drawing = false;
+ cx.notify();
+ }
+
+ fn set_brush_color(&mut self, color: Hsla, cx: &mut Context) {
+ self.brush_color = color;
+ cx.notify();
+ }
+}
+
+impl Focusable for BrushStory {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl BrushStory {
+ fn color_button(&self, color: Hsla, _label: &str, cx: &Context) -> impl IntoElement {
+ let is_selected = self.brush_color.to_hex() == color.to_hex();
+ let theme = cx.theme();
+
+ div()
+ .w(px(40.))
+ .h(px(40.))
+ .rounded(theme.radius)
+ .bg(color)
+ .border_2()
+ .when(is_selected, |this| {
+ this.border_color(theme.primary).shadow_md()
+ })
+ .when(!is_selected, |this| this.border_color(theme.border))
+ .cursor_pointer()
+ .on_mouse_down(
+ MouseButton::Left,
+ cx.listener(move |this, _, _, cx| {
+ this.set_brush_color(color, cx);
+ }),
+ )
+ }
+
+ fn render_canvas(&mut self, cx: &Context) -> impl IntoElement {
+ let theme = cx.theme();
+
+ let strokes_for_prepaint = self.strokes.clone();
+ let current_stroke_for_prepaint = self.current_stroke.clone();
+ let show_grid_for_prepaint = self.show_grid;
+ let theme_for_prepaint = theme.clone();
+
+ let state_entity = cx.entity().clone();
+
+ let base_div = div()
+ .id("canvas")
+ .size_full()
+ .bg(theme.background)
+ .cursor_crosshair()
+ .relative()
+ .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
+ .on_mouse_move(cx.listener(Self::handle_mouse_move))
+ .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
+ .child(
+ canvas(
+ move |bounds, _, cx| {
+ state_entity.update(cx, |state, _| {
+ state.canvas_bounds = Some(bounds);
+ })
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .size_full(),
+ );
+
+ base_div.child(
+ canvas(
+ move |bounds, _window, _cx| {
+ (
+ strokes_for_prepaint,
+ current_stroke_for_prepaint,
+ show_grid_for_prepaint,
+ theme_for_prepaint,
+ bounds,
+ )
+ },
+ move |_bounds,
+ (strokes, current_stroke, show_grid, theme, prepaint_bounds),
+ window,
+ _cx| {
+ let origin = prepaint_bounds.origin;
+ let size = prepaint_bounds.size;
+
+ if show_grid {
+ let grid_color = theme.border.opacity(0.2);
+ let grid_size = 40.0;
+
+ let mut x = 0.0;
+ while px(x) <= size.width {
+ let mut builder = PathBuilder::stroke(px(1.0));
+ builder.move_to(Point::new(origin.x + px(x), origin.y));
+ builder.line_to(Point::new(origin.x + px(x), origin.y + size.height));
+ if let Ok(path) = builder.build() {
+ window.paint_path(path, grid_color);
+ }
+ x += grid_size;
+ }
+
+ let mut y = 0.0;
+ while px(y) <= size.height {
+ let mut builder = PathBuilder::stroke(px(1.0));
+ builder.move_to(Point::new(origin.x, origin.y + px(y)));
+ builder.line_to(Point::new(origin.x + size.width, origin.y + px(y)));
+ if let Ok(path) = builder.build() {
+ window.paint_path(path, grid_color);
+ }
+ y += grid_size;
+ }
+ }
+
+ for stroke in strokes.iter() {
+ if let Some(path) = BrushStory::build_stroke_path(stroke, &prepaint_bounds)
+ {
+ window.paint_path(path, stroke.color);
+ }
+ }
+
+ if let Some(ref stroke) = current_stroke {
+ if let Some(path) = BrushStory::build_stroke_path(stroke, &prepaint_bounds)
+ {
+ window.paint_path(path, stroke.color);
+ }
+ }
+ },
+ )
+ .absolute()
+ .size_full(),
+ )
+ }
+
+ fn build_stroke_path(stroke: &Stroke, bounds: &Bounds) -> Option> {
+ if stroke.points.len() < 2 {
+ return None;
+ }
+
+ let mut builder = PathBuilder::stroke(px(stroke.size));
+
+ let first_point = Point::new(
+ bounds.origin.x + stroke.points[0].x,
+ bounds.origin.y + stroke.points[0].y,
+ );
+ builder.move_to(first_point);
+
+ for point in stroke.points.iter().skip(1) {
+ let abs_point = Point::new(bounds.origin.x + point.x, bounds.origin.y + point.y);
+ builder.line_to(abs_point);
+ }
+
+ builder.build().ok()
+ }
+}
+
+impl Render for BrushStory {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let theme = cx.theme();
+ let brush_size = self.brush_size.read(cx).value().start();
+ let brush_opacity = self.brush_opacity.read(cx).value().start();
+
+ v_flex()
+ .size_full()
+ .gap_6()
+ .child(
+ section("Controls").child(
+ h_flex()
+ .gap_8()
+ .w_full()
+ .items_start()
+ .child(
+ v_flex()
+ .gap_4()
+ .flex_1()
+ .w(relative(0.5))
+ .child(
+ h_flex()
+ .gap_4()
+ .items_center()
+ .child("Size:")
+ .child(
+ Slider::new(&self.brush_size)
+ .w(px(200.))
+ .bg(theme.primary)
+ .text_color(theme.primary_foreground),
+ )
+ .child(format!("{:.0}px", brush_size)),
+ )
+ .child(
+ h_flex()
+ .gap_4()
+ .items_center()
+ .child("Opacity:")
+ .child(
+ Slider::new(&self.brush_opacity)
+ .w(px(200.))
+ .bg(theme.primary)
+ .text_color(theme.primary_foreground),
+ )
+ .child(format!("{:.0}%", brush_opacity * 100.0)),
+ )
+ .child(
+ h_flex()
+ .gap_3()
+ .items_center()
+ .child(
+ Button::new("clear-canvas")
+ .icon(IconName::Close)
+ .label("Clear Canvas")
+ .small()
+ .on_click(cx.listener(|this, _, _, cx| {
+ this.clear_canvas(cx);
+ })),
+ )
+ .child(
+ Checkbox::new("show-grid")
+ .label("Show Grid")
+ .checked(self.show_grid)
+ .on_click(cx.listener(|this, checked, _, cx| {
+ this.show_grid = *checked;
+ cx.notify();
+ })),
+ ),
+ ),
+ )
+ .child(
+ v_flex()
+ .gap_2()
+ .flex_1()
+ .w(relative(0.5))
+ .child(h_flex().gap_2().items_center().child("Color:"))
+ .child(
+ h_flex()
+ .gap_3()
+ .flex_wrap()
+ .child(self.color_button(black(), "Black", cx))
+ .child(self.color_button(white(), "White", cx))
+ .child(self.color_button(red(), "Red", cx))
+ .child(self.color_button(green(), "Green", cx))
+ .child(self.color_button(blue(), "Blue", cx))
+ .child(self.color_button(yellow(), "Yellow", cx))
+ .child(self.color_button(
+ hsla(0.58, 1.0, 0.5, 1.0),
+ "Purple",
+ cx,
+ ))
+ .child(self.color_button(
+ hsla(0.083, 1.0, 0.5, 1.0),
+ "Orange",
+ cx,
+ )),
+ ),
+ ),
+ ),
+ )
+ .child(
+ section("Drawing Canvas")
+ .child(self.render_canvas(cx))
+ .flex_1(),
+ )
+ }
+}
+
+fn section(title: impl Into) -> GroupBox {
+ GroupBox::new()
+ .outline()
+ .title(
+ h_flex()
+ .justify_between()
+ .w_full()
+ .gap_4()
+ .child(title.into()),
+ )
+ .content_style(StyleRefinement::default().flex_1().size_full())
+}
+
+pub struct Example {
+ root: Entity,
+}
+
+impl Example {
+ pub fn new(window: &mut Window, cx: &mut Context) -> Self {
+ let root = cx.new(|cx| BrushStory::new(window, cx));
+ Self { root }
+ }
+
+ fn view(window: &mut Window, cx: &mut App) -> Entity {
+ cx.new(|cx| Self::new(window, cx))
+ }
+}
+
+impl Render for Example {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+ div().p_4().size_full().child(self.root.clone())
+ }
+}
+
+fn main() {
+ let app = Application::new().with_assets(Assets);
+
+ app.run(move |cx| {
+ story::init(cx);
+ cx.activate(true);
+
+ story::create_new_window("Brush Example", Example::view, cx);
+ });
+}