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) image --------- 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); + }); +}