diff --git a/.gitignore b/.gitignore index 05923927f..d0097551e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .DS_Store +*.diff diff --git a/masonry/src/action.rs b/masonry/src/action.rs index 72bd0a7bc..d0ea3a84b 100644 --- a/masonry/src/action.rs +++ b/masonry/src/action.rs @@ -17,6 +17,8 @@ pub enum Action { TextChanged(String), TextEntered(String), CheckboxChecked(bool), + SliderValueChanged(f64), + SliderEditingChanged(bool), // FIXME - This is a huge hack Other(Box), } @@ -28,6 +30,8 @@ impl PartialEq for Action { (Self::TextChanged(l0), Self::TextChanged(r0)) => l0 == r0, (Self::TextEntered(l0), Self::TextEntered(r0)) => l0 == r0, (Self::CheckboxChecked(l0), Self::CheckboxChecked(r0)) => l0 == r0, + (Self::SliderValueChanged(l0), Self::SliderValueChanged(r0)) => l0 == r0, + (Self::SliderEditingChanged(l0), Self::SliderEditingChanged(r0)) => l0 == r0, // FIXME // (Self::Other(val_l), Self::Other(val_r)) => false, _ => false, @@ -42,6 +46,10 @@ impl std::fmt::Debug for Action { Self::TextChanged(text) => f.debug_tuple("TextChanged").field(text).finish(), Self::TextEntered(text) => f.debug_tuple("TextEntered").field(text).finish(), Self::CheckboxChecked(b) => f.debug_tuple("CheckboxChecked").field(b).finish(), + Self::SliderValueChanged(b) => f.debug_tuple("SliderValueChanged").field(b).finish(), + Self::SliderEditingChanged(b) => { + f.debug_tuple("SliderEditingChanged").field(b).finish() + } Self::Other(_) => write!(f, "Other(...)"), } } diff --git a/masonry/src/widget/mod.rs b/masonry/src/widget/mod.rs index d7434567e..cbcaf51ca 100644 --- a/masonry/src/widget/mod.rs +++ b/masonry/src/widget/mod.rs @@ -26,6 +26,7 @@ mod prose; mod root_widget; mod scroll_bar; mod sized_box; +mod slider; mod spinner; mod split; mod text_area; @@ -35,6 +36,7 @@ mod widget_arena; mod zstack; pub use self::image::Image; +pub use slider::Slider; pub use align::Align; pub use button::Button; pub use checkbox::Checkbox; diff --git a/masonry/src/widget/sized_box.rs b/masonry/src/widget/sized_box.rs index 3783f16c3..24ceeb68e 100644 --- a/masonry/src/widget/sized_box.rs +++ b/masonry/src/widget/sized_box.rs @@ -6,8 +6,9 @@ use accesskit::{Node, Role}; use smallvec::{smallvec, SmallVec}; use tracing::{trace_span, warn, Span}; +use vello::kurbo::Vec2; use vello::kurbo::{Affine, RoundedRectRadii}; -use vello::peniko::{Brush, Fill}; +use vello::peniko::{Brush, Color, Fill}; use vello::Scene; use crate::paint_scene_helpers::stroke; @@ -25,6 +26,23 @@ struct BorderStyle { brush: Brush, } +/// Defines the style of a shadow +struct ShadowStyle { + /// Shadow color + color: Color, + /// Shadow offset from the element + offset: Vec2, + /// Shadow blur radius + blur_radius: f64, + /// Shadow spread radius + spread_radius: f64, + /// The corner radius of the shadow. + /// + /// If `None`, the shadow will use the same corner radius as the widget's background. + /// If `Some(radius)`, the shadow will use the specified radius for its corners. + corner_radius: Option, +} + /// Padding specifies the spacing between the edges of the box and the child view. /// /// A Padding can also be constructed using [`from(value: f64)`][Self::from] @@ -62,6 +80,7 @@ pub struct SizedBox { height: Option, background: Option, border: Option, + shadow: Option, corner_radius: RoundedRectRadii, padding: Padding, } @@ -191,6 +210,7 @@ impl SizedBox { height: None, background: None, border: None, + shadow: None, corner_radius: RoundedRectRadii::from_single_radius(0.0), padding: Padding::ZERO, } @@ -204,6 +224,7 @@ impl SizedBox { height: None, background: None, border: None, + shadow: None, corner_radius: RoundedRectRadii::from_single_radius(0.0), padding: Padding::ZERO, } @@ -217,6 +238,7 @@ impl SizedBox { height: None, background: None, border: None, + shadow: None, corner_radius: RoundedRectRadii::from_single_radius(0.0), padding: Padding::ZERO, } @@ -234,6 +256,7 @@ impl SizedBox { height: None, background: None, border: None, + shadow: None, corner_radius: RoundedRectRadii::from_single_radius(0.0), padding: Padding::ZERO, } @@ -301,6 +324,25 @@ impl SizedBox { self } + /// Builder-style method for adding a shadow to the widget. + pub fn shadow( + mut self, + color: impl Into, + offset: impl Into, + blur_radius: impl Into, + spread_radius: impl Into, + corner_radius: impl Into>, + ) -> Self { + self.shadow = Some(ShadowStyle { + color: color.into(), + offset: offset.into(), + blur_radius: blur_radius.into(), + spread_radius: spread_radius.into(), + corner_radius: corner_radius.into(), + }); + self + } + /// Builder style method for rounding off corners of this container by setting a corner radius pub fn rounded(mut self, radius: impl Into) -> Self { self.corner_radius = radius.into(); @@ -405,6 +447,31 @@ impl SizedBox { this.ctx.request_layout(); } + /// Add a shadow to the widget. + pub fn set_shadow( + this: &mut WidgetMut<'_, Self>, + color: impl Into, + offset: impl Into, + blur_radius: impl Into, + spread_radius: impl Into, + corner_radius: impl Into>, + ) { + this.widget.shadow = Some(ShadowStyle { + color: color.into(), + offset: offset.into(), + blur_radius: blur_radius.into(), + spread_radius: spread_radius.into(), + corner_radius: corner_radius.into(), + }); + this.ctx.request_paint_only(); + } + + /// Clears shadow. + pub fn clear_shadow(this: &mut WidgetMut<'_, Self>) { + this.widget.shadow = None; + this.ctx.request_paint_only(); + } + /// Round off corners of this container by setting a corner radius pub fn set_rounded(this: &mut WidgetMut<'_, Self>, radius: impl Into) { this.widget.corner_radius = radius.into(); @@ -520,6 +587,24 @@ impl Widget for SizedBox { fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { let corner_radius = self.corner_radius; + let size = ctx.size(); + + // Paint shadow if present + if let Some(shadow) = &self.shadow { + let shadow_rect = size + .to_rect() + .inset(-shadow.spread_radius) + .to_rounded_rect(corner_radius); + + scene.draw_blurred_rounded_rect_in( + &shadow_rect, + Affine::translate(shadow.offset), + shadow_rect.rect(), + shadow.color, + shadow.corner_radius.unwrap_or(corner_radius.top_left), + shadow.blur_radius, + ); + } if let Some(background) = self.background.as_mut() { let panel = ctx.size().to_rounded_rect(corner_radius); @@ -716,5 +801,49 @@ mod tests { assert_render_snapshot!(harness, "label_box_with_outer_padding"); } + #[test] + fn label_box_with_shadow() { + let widget = SizedBox::new(Label::new("hello")) + .width(40.0) + .height(40.0) + .background(palette::css::WHITE) + .shadow(palette::css::BLACK, (5.0, 5.0), 10.0, 0.0, None); + + let mut harness = TestHarness::create(widget); + + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "label_box_with_shadow"); + } + + #[test] + fn label_box_with_shadow_and_border() { + let widget = SizedBox::new(Label::new("hello")) + .width(40.0) + .height(40.0) + .background(palette::css::WHITE) + .border(palette::css::BLUE, 2.0) + .shadow(palette::css::BLACK, (5.0, 5.0), 10.0, 0.0, None); + + let mut harness = TestHarness::create(widget); + + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "label_box_with_shadow_and_border"); + } + + #[test] + fn label_box_with_shadow_and_rounded_corners() { + let widget = SizedBox::new(Label::new("hello")) + .width(40.0) + .height(40.0) + .background(palette::css::WHITE) + .rounded(10.0) + .shadow(palette::css::BLACK, (5.0, 5.0), 10.0, 0.0, None); + + let mut harness = TestHarness::create(widget); + + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "label_box_with_shadow_and_rounded_corners"); + } + // TODO - add screenshot tests for different brush types } diff --git a/masonry/src/widget/slider.rs b/masonry/src/widget/slider.rs new file mode 100644 index 000000000..c4acd1752 --- /dev/null +++ b/masonry/src/widget/slider.rs @@ -0,0 +1,564 @@ +// Copyright 2023 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A slider widget for selecting a value within a range. + +use accesskit::{Node, Role}; +use cursor_icon::CursorIcon; +use smallvec::SmallVec; +use tracing::{trace_span, Span}; +use vello::{ + kurbo::{Affine, Point, Rect, RoundedRect, RoundedRectRadii, Size}, + Scene, +}; + +use crate::{ + theme, widget::Axis, AccessCtx, AccessEvent, Action, BoxConstraints, Color, EventCtx, + LayoutCtx, PaintCtx, PointerButton, PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, + UpdateCtx, Widget, WidgetId, +}; + +use super::WidgetMut; + +/// A slider widget for selecting a value within a range. +pub struct Slider { + axis: Axis, + value: f64, + min: f64, + max: f64, + step: f64, + color: Color, + track_color: Color, + editing: bool, + is_dragging: bool, + grab_anchor: Option, + thumb_radii: RoundedRectRadii, + track_radii: RoundedRectRadii, + is_hovered: bool, + hover_glow_color: Color, + hover_glow_blur_radius: f64, + hover_glow_spread_radius: f64, + track_rect: Option, +} + +impl Slider { + const DEFAULT_WIDTH: f64 = 200.0; + const DEFAULT_HEIGHT: f64 = 40.0; + const BASE_TRACK_THICKNESS: f64 = 4.0; + const TRACK_PADDING: f64 = 20.0; + const THUMB_WIDTH: f64 = 12.0; + const THUMB_HEIGHT: f64 = 20.0; + + /// Create a new slider with the given range and initial value. + pub fn new(axis: Axis, min: f64, max: f64, value: f64) -> Self { + Self { + axis, + value: value.clamp(min, max), + min, + max, + step: 1.0, + color: theme::PRIMARY_LIGHT, + track_color: theme::PRIMARY_DARK, + editing: false, + is_dragging: false, + grab_anchor: None, + thumb_radii: RoundedRectRadii::from_single_radius(5.0), + track_radii: RoundedRectRadii::from_single_radius(2.0), + is_hovered: false, + hover_glow_color: Color::from_rgba8(255, 255, 255, 50), + hover_glow_blur_radius: 5.0, + hover_glow_spread_radius: 2.0, + track_rect: None, + } + } + + /// Builder-style method for setting the slider's color. + pub fn with_color(mut self, color: impl Into) -> Self { + self.color = color.into(); + self + } + + /// Builder-style method for setting the slider's track color. + pub fn with_track_color(mut self, track_color: impl Into) -> Self { + self.track_color = track_color.into(); + self + } + + /// Builder-style method for setting the slider's step amount. + pub fn with_step(mut self, step: f64) -> Self { + self.step = step; + self + } + + /// Builder-style method for setting the slider's thumb radii. + pub fn with_thumb_radii(mut self, radii: impl Into) -> Self { + self.thumb_radii = radii.into(); + self + } + + /// Builder-style method for setting the slider's track radii. + pub fn with_track_radii(mut self, radii: impl Into) -> Self { + self.track_radii = radii.into(); + self + } + + /// Builder-style method for setting the hover glow color. + pub fn with_hover_glow_color(mut self, color: impl Into) -> Self { + self.hover_glow_color = color.into(); + self + } + + /// Builder-style method for setting the hover glow blur radius. + pub fn with_hover_glow_blur_radius(mut self, blur_radius: f64) -> Self { + self.hover_glow_blur_radius = blur_radius; + self + } + + /// Builder-style method for setting the hover glow spread radius. + pub fn with_hover_glow_spread_radius(mut self, spread_radius: f64) -> Self { + self.hover_glow_spread_radius = spread_radius; + self + } +} + +impl Slider { + /// Set the slider's value. + pub fn set_value(this: &mut WidgetMut<'_, Self>, value: f64) { + this.widget.value = value.clamp(this.widget.min, this.widget.max); + this.ctx.request_paint_only(); + } + + /// Set the slider's color. + pub fn set_color(this: &mut WidgetMut<'_, Self>, color: impl Into) { + this.widget.color = color.into(); + this.ctx.request_paint_only(); + } + + /// Set the slider's track color. + pub fn set_track_color(this: &mut WidgetMut<'_, Self>, track_color: impl Into) { + this.widget.track_color = track_color.into(); + this.ctx.request_paint_only(); + } + + /// Set the slider's step amount. + pub fn set_step(this: &mut WidgetMut<'_, Self>, step: f64) { + this.widget.step = step; + this.ctx.request_paint_only(); + } + + /// Set the slider's thumb radii. + pub fn set_thumb_radii(this: &mut WidgetMut<'_, Self>, radii: impl Into) { + this.widget.thumb_radii = radii.into(); + this.ctx.request_paint_only(); + } + + /// Set the slider's track radii. + pub fn set_track_radii(this: &mut WidgetMut<'_, Self>, radii: impl Into) { + this.widget.track_radii = radii.into(); + this.ctx.request_paint_only(); + } + + /// Set the hover glow color. + pub fn set_hover_glow_color(this: &mut WidgetMut<'_, Self>, color: impl Into) { + this.widget.hover_glow_color = color.into(); + this.ctx.request_paint_only(); + } + + /// Set the hover glow blur radius. + pub fn set_hover_glow_blur_radius(this: &mut WidgetMut<'_, Self>, blur_radius: f64) { + this.widget.hover_glow_blur_radius = blur_radius; + this.ctx.request_paint_only(); + } + + /// Set the hover glow spread radius. + pub fn set_hover_glow_spread_radius(this: &mut WidgetMut<'_, Self>, spread_radius: f64) { + this.widget.hover_glow_spread_radius = spread_radius; + this.ctx.request_paint_only(); + } +} + +impl Widget for Slider { + fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) { + match event { + PointerEvent::PointerDown(PointerButton::Primary, state) => { + if !ctx.is_disabled() { + ctx.capture_pointer(); + let layout_size = ctx.size(); + let thumb_rect = self.get_thumb_rect(layout_size); + + let mouse_pos = Point::new(state.position.x, state.position.y) + - ctx.window_origin().to_vec2(); + if thumb_rect.contains(mouse_pos) { + let (z0, z1) = self.axis.major_span(thumb_rect); + let mouse_major = self.axis.major_pos(mouse_pos); + self.grab_anchor = Some((mouse_major - z0) / (z1 - z0)); + } else { + self.value = self.value_from_mouse_pos(layout_size, 0.5, mouse_pos); + self.round_to_step(); + ctx.submit_action(Action::SliderValueChanged(self.value)); + self.grab_anchor = Some(0.5); + } + + self.is_dragging = true; + self.editing = true; + ctx.submit_action(Action::SliderEditingChanged(true)); + ctx.request_paint_only(); + } + } + PointerEvent::PointerUp(_, _) => { + if self.is_dragging { + self.is_dragging = false; + self.grab_anchor = None; + ctx.release_pointer(); + self.editing = false; + ctx.submit_action(Action::SliderEditingChanged(false)); + ctx.request_paint_only(); + } + } + PointerEvent::PointerMove(state) => { + if self.is_dragging { + let mouse_pos = Point::new(state.position.x, state.position.y) + - ctx.window_origin().to_vec2(); + if let Some(grab_anchor) = self.grab_anchor { + self.value = self.value_from_mouse_pos(ctx.size(), grab_anchor, mouse_pos); + self.round_to_step(); + ctx.submit_action(Action::SliderValueChanged(self.value)); + } + ctx.request_paint_only(); + } else { + let mouse_pos = Point::new(state.position.x, state.position.y) + - ctx.window_origin().to_vec2(); + let thumb_rect = self.get_thumb_rect(ctx.size()); + let was_hovered = self.is_hovered; + self.is_hovered = thumb_rect.contains(mouse_pos); + if was_hovered != self.is_hovered { + ctx.request_paint_only(); + } + } + } + PointerEvent::PointerLeave(_) => { + if self.is_hovered { + self.is_hovered = false; + ctx.request_paint_only(); + } + } + _ => {} + } + } + + fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {} + + fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {} + + fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) { + match event { + Update::HoveredChanged(_) | Update::FocusChanged(_) | Update::DisabledChanged(_) => { + ctx.request_paint_only(); + } + _ => {} + } + } + + fn register_children(&mut self, _ctx: &mut RegisterCtx) {} + + fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { + let (width, height) = match self.axis { + Axis::Horizontal => { + let width = if bc.is_width_bounded() { + bc.max().width + } else { + Self::DEFAULT_WIDTH + }; + let height = if bc.is_height_bounded() { + bc.max().height.min(Self::DEFAULT_HEIGHT) + } else { + Self::DEFAULT_HEIGHT + }; + (width, height) + } + Axis::Vertical => { + let width = if bc.is_width_bounded() { + bc.max().width.min(Self::DEFAULT_HEIGHT) + } else { + Self::DEFAULT_HEIGHT + }; + let height = if bc.is_height_bounded() { + bc.max().height + } else { + Self::DEFAULT_WIDTH + }; + (width, height) + } + }; + + let size = bc.constrain(Size::new(width, height)); + + // 计算滑轨位置和尺寸 + let track_rect = match self.axis { + Axis::Horizontal => { + let y_center = size.height / 2.0; + Rect::new( + Self::TRACK_PADDING, + y_center - Self::BASE_TRACK_THICKNESS / 2.0, + size.width - Self::TRACK_PADDING, + y_center + Self::BASE_TRACK_THICKNESS / 2.0, + ) + } + Axis::Vertical => { + let x_center = size.width / 2.0; + Rect::new( + x_center - Self::BASE_TRACK_THICKNESS / 2.0, + Self::TRACK_PADDING, + x_center + Self::BASE_TRACK_THICKNESS / 2.0, + size.height - Self::TRACK_PADDING, + ) + } + } + .to_rounded_rect(self.track_radii); + + self.track_rect = Some(track_rect); + size + } + + fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { + let size = ctx.size(); + + if let Some(track_rect) = &self.track_rect { + scene.fill( + vello::peniko::Fill::NonZero, + Affine::IDENTITY, + self.track_color, + None, + track_rect, + ); + } + + let thumb_rect = self.get_thumb_rect(size); + if self.is_hovered { + let glow_rect = thumb_rect + .inflate(self.hover_glow_spread_radius, self.hover_glow_spread_radius) + .to_rounded_rect(inflate( + &self.thumb_radii, + self.hover_glow_spread_radius, + self.hover_glow_spread_radius, + )); + let rect = thumb_rect.inflate(1.0, 1.0); + scene.draw_blurred_rounded_rect_in( + &glow_rect, + Affine::IDENTITY, + rect, + self.hover_glow_color, + self.thumb_radii.top_left + self.hover_glow_spread_radius, + self.hover_glow_blur_radius, + ); + } + scene.fill( + vello::peniko::Fill::NonZero, + Affine::IDENTITY, + self.color, + None, + &thumb_rect.to_rounded_rect(self.thumb_radii), + ); + } + + fn accessibility_role(&self) -> Role { + Role::Slider + } + + fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut Node) { + node.set_value(self.value.to_string()); + node.add_action(accesskit::Action::SetValue); + } + + fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { + SmallVec::new() + } + + fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span { + trace_span!("Slider", id = ctx.widget_id().trace()) + } + + fn get_debug_text(&self) -> Option { + Some(self.value.to_string()) + } + + fn get_cursor(&self, _ctx: &QueryCtx, _pos: Point) -> CursorIcon { + CursorIcon::Text + } +} + +impl Slider { + fn compute_max_intrinsic(&mut self, _ctx: &mut LayoutCtx, axis: Axis, _cross: f64) -> f64 { + match (axis, self.axis) { + (Axis::Horizontal, Axis::Horizontal) => Self::DEFAULT_WIDTH, + (Axis::Vertical, Axis::Vertical) => Self::DEFAULT_WIDTH, + (Axis::Horizontal, Axis::Vertical) => Self::DEFAULT_HEIGHT, + (Axis::Vertical, Axis::Horizontal) => Self::DEFAULT_HEIGHT, + } + } + + fn round_to_step(&mut self) { + self.value = ((self.value - self.min) / self.step).round() * self.step + self.min; + self.value = self.value.clamp(self.min, self.max); + } + + fn get_thumb_rect(&self, layout_size: Size) -> Rect { + let track_rect = self.track_rect.as_ref().expect("track_rect should be set"); + let size_ratio = (self.value - self.min) / (self.max - self.min); + + match self.axis { + Axis::Horizontal => { + let x = + track_rect.rect().x0 + size_ratio * (track_rect.width() - Self::THUMB_WIDTH); + let y = layout_size.height / 2.0 - Self::THUMB_HEIGHT / 2.0; + Rect::from_origin_size( + Point::new(x, y), + Size::new(Self::THUMB_WIDTH, Self::THUMB_HEIGHT), + ) + } + Axis::Vertical => { + let x = layout_size.width / 2.0 - Self::THUMB_HEIGHT / 2.0; + let y = + track_rect.rect().y0 + size_ratio * (track_rect.height() - Self::THUMB_WIDTH); + Rect::from_origin_size( + Point::new(x, y), + Size::new(Self::THUMB_HEIGHT, Self::THUMB_WIDTH), + ) + } + } + } + + fn value_from_mouse_pos(&self, layout_size: Size, anchor: f64, mouse_pos: Point) -> f64 { + let thumb_rect = self.get_thumb_rect(layout_size); + let thumb_width = self.axis.major(thumb_rect.size()); + let new_thumb_pos_major = self.axis.major_pos(mouse_pos) - anchor * thumb_width; + + let track_rect = self.track_rect.as_ref().expect("track_rect should be set"); + let track_length = match self.axis { + Axis::Horizontal => track_rect.width(), + Axis::Vertical => track_rect.height(), + }; + + let track_pos = match self.axis { + Axis::Horizontal => track_rect.rect().x0, + Axis::Vertical => track_rect.rect().y0, + }; + + let normalized_pos = (new_thumb_pos_major - track_pos) / track_length; + let new_value = self.min + normalized_pos * (self.max - self.min); + new_value.clamp(self.min, self.max) + } +} + +/// Inflate the radii by the given amounts. +pub fn inflate(raddi: &RoundedRectRadii, width: f64, height: f64) -> RoundedRectRadii { + RoundedRectRadii::new( + raddi.top_left + width, + raddi.top_right + width, + raddi.bottom_right + height, + raddi.bottom_left + height, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::palette; + use crate::testing::TestHarness; + + #[test] + fn test_slider_drag() { + let slider = Slider::new(Axis::Horizontal, 0.0, 100.0, 50.0) + .with_color(palette::css::BLUE) + .with_track_color(palette::css::LIGHT_GRAY); + + let mut harness = TestHarness::create(slider); + let slider_id = harness.root_widget().id(); + + harness.mouse_click_on(slider_id); + assert_eq!( + harness + .get_widget(slider_id) + .downcast::() + .unwrap() + .value, + 50.0 + ); + + harness.mouse_move(Point::new(150.0, 10.0)); + assert_eq!( + harness + .get_widget(slider_id) + .downcast::() + .unwrap() + .value, + 75.0 + ); + + harness.mouse_move(Point::new(50.0, 10.0)); + assert_eq!( + harness + .get_widget(slider_id) + .downcast::() + .unwrap() + .value, + 25.0 + ); + } + + #[test] + fn test_slider_step() { + let slider = Slider::new(Axis::Horizontal, 0.0, 100.0, 50.0) + .with_step(10.0) + .with_color(palette::css::BLUE) + .with_track_color(palette::css::LIGHT_GRAY); + + let mut harness = TestHarness::create(slider); + let slider_id = harness.root_widget().id(); + + harness.mouse_click_on(slider_id); + harness.mouse_move(Point::new(150.0, 10.0)); + assert_eq!( + harness + .get_widget(slider_id) + .downcast::() + .unwrap() + .value, + 80.0 + ); + } + + #[test] + fn test_slider_bounds() { + let slider = Slider::new(Axis::Horizontal, 0.0, 100.0, 50.0) + .with_color(palette::css::BLUE) + .with_track_color(palette::css::LIGHT_GRAY); + + let mut harness = TestHarness::create(slider); + let slider_id = harness.root_widget().id(); + + harness.mouse_click_on(slider_id); + + // Test upper bound + harness.mouse_move(Point::new(1000.0, 10.0)); + assert_eq!( + harness + .get_widget(slider_id) + .downcast::() + .unwrap() + .value, + 100.0 + ); + + // Test lower bound + harness.mouse_move(Point::new(-1000.0, 10.0)); + assert_eq!( + harness + .get_widget(slider_id) + .downcast::() + .unwrap() + .value, + 0.0 + ); + } +} diff --git a/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow.snap b/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow.snap new file mode 100644 index 000000000..83e3661a6 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/sized_box.rs +expression: harness.root_widget() +--- +SizedBox( + Label, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow_and_border.snap b/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow_and_border.snap new file mode 100644 index 000000000..83e3661a6 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow_and_border.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/sized_box.rs +expression: harness.root_widget() +--- +SizedBox( + Label, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow_and_rounded_corners.snap b/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow_and_rounded_corners.snap new file mode 100644 index 000000000..83e3661a6 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__sized_box__tests__label_box_with_shadow_and_rounded_corners.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/sized_box.rs +expression: harness.root_widget() +--- +SizedBox( + Label, +) diff --git a/xilem/examples/shadow.rs b/xilem/examples/shadow.rs new file mode 100644 index 000000000..dbbb33ea0 --- /dev/null +++ b/xilem/examples/shadow.rs @@ -0,0 +1,199 @@ +//! A demo showing how to use shadows in Xilem. +//! +//! This example demonstrates: +//! - How to create and customize shadow effects +//! - How to build responsive layouts that work on both desktop and mobile +//! - How to structure complex widget hierarchies +//! - Component composition patterns for reusability + +use masonry::widget::{CrossAxisAlignment, MainAxisAlignment}; +use winit::dpi::LogicalSize; +use winit::error::EventLoopError; +use winit::window::Window; +use xilem::palette::css; +use xilem::view::{ + button, flex, label, portal, prose, sized_box, slider, Axis, FlexExt, FlexSpacer, Padding, +}; +use xilem::{Color, EventLoop, EventLoopBuilder, TextAlignment, WidgetView, Xilem}; + +/// The main application state containing all shadow configuration parameters +struct AppState { + /// Horizontal offset of the shadow + offset_x: f64, + /// Vertical offset of the shadow + offset_y: f64, + /// Blur radius of the shadow + blur_radius: f64, + /// Spread radius of the shadow + spread_radius: f64, + /// Corner radius of the shadow + corner_radius: f64, + /// Color of the shadow + shadow_color: Color, +} + +impl Default for AppState { + fn default() -> Self { + Self { + offset_x: 12.5, + offset_y: 12.5, + blur_radius: 20.0, + spread_radius: 3.0, + corner_radius: 24.0, + shadow_color: css::RED, + } + } +} + +/// A reusable control component for adjusting shadow parameters +struct ShadowControl { + /// Label text for the control + label: String, + /// Current value of the control + value: f64, + /// Valid range for the control value + range: std::ops::Range, +} + +impl ShadowControl { + /// Creates a new shadow control with the given label, initial value and range + fn new(label: &str, value: f64, range: std::ops::Range) -> Self { + Self { + label: label.to_string(), + value, + range, + } + } + + /// Renders the control as a labeled slider + fn view(&self, on_change: F) -> impl WidgetView + where + F: Fn(&mut AppState, f64) + 'static + Send + Sync, + { + flex(( + label(format!("{}: {:.1}", self.label, self.value)), + slider(self.range.clone(), self.value, on_change) + .with_hover_glow_color(css::LIGHT_BLUE) + .with_hover_glow_blur_radius(8.0) + .with_hover_glow_spread_radius(2.0) + .with_step(0.1), + )) + .direction(Axis::Vertical) + .cross_axis_alignment(CrossAxisAlignment::Center) + } +} + +impl AppState { + /// Renders the control panel containing all shadow adjustment controls + fn controls_panel(&mut self) -> impl WidgetView { + sized_box( + flex(( + prose("Shadow Controls") + .text_size(20.) + .alignment(TextAlignment::Middle), + ShadowControl::new("Offset X", self.offset_x, -50.0..50.0) + .view(|state, val| state.offset_x = val), + ShadowControl::new("Offset Y", self.offset_y, -50.0..50.0) + .view(|state, val| state.offset_y = val), + ShadowControl::new("Blur Radius", self.blur_radius, 0.0..50.0) + .view(|state, val| state.blur_radius = val), + ShadowControl::new("Spread Radius", self.spread_radius, -20.0..20.0) + .view(|state, val| state.spread_radius = val), + ShadowControl::new("Corner Radius", self.corner_radius, 0.0..150.0) + .view(|state, val| state.corner_radius = val), + sized_box( + flex(( + FlexSpacer::Flex(10.0), + button("Toggle Color", |state: &mut AppState| { + state.shadow_color = if state.shadow_color == css::BLUE { + css::RED + } else { + css::BLUE + }; + }), + FlexSpacer::Flex(10.0), + )) + .direction(Axis::Horizontal), + ) + .padding(8.) // 添加内边距 + .rounded(4.), + )) + .direction(Axis::Vertical) + .cross_axis_alignment(CrossAxisAlignment::Start) + .main_axis_alignment(MainAxisAlignment::Start), + ) + .padding(16.) + } + + /// Renders the preview panel showing the shadow effect + fn preview_panel(&self) -> impl WidgetView { + sized_box( + flex( + label("label") + .text_size(50.0) + .brush(css::BLACK) + .alignment(TextAlignment::Middle), + ) + .direction(Axis::Vertical) + .main_axis_alignment(MainAxisAlignment::Center), + ) + .width(200.0) + .height(200.0) + .background(Color::WHITE) + .rounded(self.corner_radius) + .shadow( + self.shadow_color, + (self.offset_x, self.offset_y), + self.blur_radius, + self.spread_radius, + Some(self.corner_radius), + ) + } + + /// Renders the main application view + fn view(&mut self) -> impl WidgetView { + flex(( + FlexSpacer::Fixed(40.), + portal( + flex(( + sized_box(self.preview_panel()) + .padding(Padding::all(16.)) + .background(css::LIGHT_GRAY.with_alpha(0.1)), + self.controls_panel(), + )) + .direction(Axis::Vertical), + ) + .flex(1.), + )) + .direction(Axis::Vertical) + .must_fill_major_axis(true) + } +} + +fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> { + let data = AppState::default(); + let app = Xilem::new(data, AppState::view); + + let window_attributes = Window::default_attributes() + .with_title("Shadow Example") + .with_resizable(false) // 禁止调整窗口大小 + .with_inner_size(LogicalSize::new(400., 800.)); // 设置合适的竖屏尺寸 + + app.run_windowed_in(event_loop, window_attributes) +} + +#[cfg(not(target_os = "android"))] +fn main() -> Result<(), EventLoopError> { + run(EventLoop::with_user_event()) +} + +#[cfg(target_os = "android")] +#[no_mangle] +fn android_main(app: winit::platform::android::activity::AndroidApp) { + use winit::platform::android::EventLoopBuilderExtAndroid; + + let mut event_loop = EventLoop::with_user_event(); + event_loop.with_android_app(app); + + run(event_loop).expect("Can create app"); +} diff --git a/xilem/src/view/mod.rs b/xilem/src/view/mod.rs index fd8e39eeb..a9b6c9b89 100644 --- a/xilem/src/view/mod.rs +++ b/xilem/src/view/mod.rs @@ -50,3 +50,6 @@ pub use portal::*; mod zstack; pub use zstack::*; + +mod slider; +pub use slider::*; diff --git a/xilem/src/view/sized_box.rs b/xilem/src/view/sized_box.rs index b73dd8c9c..1cda6e0e9 100644 --- a/xilem/src/view/sized_box.rs +++ b/xilem/src/view/sized_box.rs @@ -3,8 +3,8 @@ use std::marker::PhantomData; -use masonry::widget; pub use masonry::widget::Padding; +use masonry::{widget, Color, Vec2}; use vello::kurbo::RoundedRectRadii; use vello::peniko::Brush; @@ -25,6 +25,7 @@ where height: None, width: None, background: None, + shadow: None, border: None, corner_radius: RoundedRectRadii::from_single_radius(0.0), padding: Padding::ZERO, @@ -41,9 +42,28 @@ pub struct SizedBox { border: Option, corner_radius: RoundedRectRadii, padding: Padding, + shadow: Option, phantom: PhantomData (State, Action)>, } +/// Style properties for a shadow +#[derive(PartialEq)] +pub struct ShadowStyle { + /// Shadow color + pub color: Color, + /// Shadow offset from the element + pub offset: Vec2, + /// Shadow blur radius + pub blur_radius: f64, + /// Shadow spread radius + pub spread_radius: f64, + /// The corner radius of the shadow. + /// + /// If `None`, the shadow will use the same corner radius as the widget's background. + /// If `Some(radius)`, the shadow will use the specified radius for its corners. + pub corner_radius: Option, +} + impl SizedBox { /// Set container's width. pub fn width(mut self, width: f64) -> Self { @@ -119,6 +139,25 @@ impl SizedBox { self.padding = padding.into(); self } + + /// Builder-style method for adding a shadow to the widget. + pub fn shadow( + mut self, + color: impl Into, + offset: impl Into, + blur_radius: impl Into, + spread_radius: impl Into, + corner_radius: impl Into>, + ) -> Self { + self.shadow = Some(ShadowStyle { + color: color.into(), + offset: offset.into(), + blur_radius: blur_radius.into(), + spread_radius: spread_radius.into(), + corner_radius: corner_radius.into(), + }); + self + } } impl ViewMarker for SizedBox {} @@ -144,6 +183,15 @@ where if let Some(border) = &self.border { widget = widget.border(border.brush.clone(), border.width); } + if let Some(shadow) = &self.shadow { + widget = widget.shadow( + shadow.color, + shadow.offset, + shadow.blur_radius, + shadow.spread_radius, + shadow.corner_radius, + ); + } (ctx.new_pod(widget), child_state) } @@ -188,6 +236,21 @@ where if self.padding != prev.padding { widget::SizedBox::set_padding(&mut element, self.padding); } + if self.shadow != prev.shadow { + match &self.shadow { + Some(shadow) => { + widget::SizedBox::set_shadow( + &mut element, + shadow.color.clone(), + shadow.offset, + shadow.blur_radius, + shadow.spread_radius, + shadow.corner_radius, + ); + } + None => widget::SizedBox::clear_shadow(&mut element), + } + } { let mut child = widget::SizedBox::child_mut(&mut element) .expect("We only create SizedBox with a child"); diff --git a/xilem/src/view/slider.rs b/xilem/src/view/slider.rs new file mode 100644 index 000000000..51dc8d181 --- /dev/null +++ b/xilem/src/view/slider.rs @@ -0,0 +1,302 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use core::ops::Range; + +use masonry::widget::{self, Axis, Padding, Slider as MasonrySlider}; +use vello::kurbo::RoundedRectRadii; +use xilem_core::{DynMessage, MessageResult, Mut, View, ViewId, ViewMarker}; + +use crate::{Pod, ViewCtx}; + +use super::Label; + +type OnChange = Box Action + Send + Sync + 'static>; +type OnEditingChanged = + Box Action + Sync + Send + 'static>; + +/// A slider widget for selecting a value within a range. +/// +/// # Example +/// ```rust +/// use xilem::view::slider; +/// use xilem::Color; +/// +/// slider(0.0, 1.0, 0.5) +/// .on_change(|value| println!("Slider value: {}", value)) +/// .with_color(Color::rgb8(100, 150, 200)); +/// ``` +pub fn slider( + range: Range, + value: f64, + on_change: impl Fn(&mut State, f64) -> Action + Send + Sync + 'static, +) -> Slider { + Slider { + min: range.start, + max: range.end, + value: value.clamp(range.start, range.end), + on_change: Box::new(on_change), + on_editing_changed: None, + color: None, + track_color: None, + step: None, + axis: Axis::Horizontal, + label: None, + min_label: None, + max_label: None, + label_alignment: None, + label_padding: None, + thumb_radii: None, + track_radii: None, + hover_glow_color: None, + hover_glow_blur_radius: None, + hover_glow_spread_radius: None, + } +} + +/// A slider view that allows selecting a value within a range. +pub struct Slider { + min: f64, + max: f64, + value: f64, + on_change: OnChange, + on_editing_changed: Option>, + color: Option, + track_color: Option, + step: Option, + axis: Axis, + label: Option