diff --git a/masonry/src/testing/harness.rs b/masonry/src/testing/harness.rs index 10c2104ed2..88c1a2804c 100644 --- a/masonry/src/testing/harness.rs +++ b/masonry/src/testing/harness.rs @@ -715,7 +715,10 @@ impl TestHarness { // Remove '.new.png' file if it exists let _ = std::fs::remove_file(&new_path); new_image.save(&new_path).unwrap(); - panic!("Snapshot test '{test_name}' failed: No reference file"); + panic!( + "Snapshot test '{test_name}' failed: No reference file\n\ + New screenshot created with `.new.png` extension. If correct, change to `.png`" + ); } } } diff --git a/masonry/src/widgets/canvas.rs b/masonry/src/widgets/canvas.rs new file mode 100644 index 0000000000..5a531c7a71 --- /dev/null +++ b/masonry/src/widgets/canvas.rs @@ -0,0 +1,197 @@ +// Copyright 2025 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A canvas widget. + +use std::sync::Arc; + +use accesskit::{Node, Role}; +use smallvec::SmallVec; +use tracing::{Span, trace_span}; +use vello::Scene; +use vello::kurbo::Size; + +use crate::core::{ + AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent, + PropertiesMut, PropertiesRef, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, + WidgetId, WidgetMut, +}; + +/// A widget allowing custom drawing. +pub struct Canvas { + draw: Arc, + alt_text: Option, +} + +// --- MARK: BUILDERS --- +impl Canvas { + /// Create a new canvas with the given draw function. + pub fn new(draw: impl Fn(&mut Scene, Size) + Send + Sync + 'static) -> Self { + Self::from_arc(Arc::new(draw)) + } + + /// Create a new canvas from a function already contained in an [`Arc`]. + pub fn from_arc(draw: Arc) -> Self { + Self { + draw, + alt_text: None, + } + } + + /// Set the text that will be used to communicate the meaning of the canvas to + /// those using screen readers. + /// + /// Users are encouraged to set alt text for the canvas. + /// If possible, the alt-text should succinctly describe what the canvas represents. + /// + /// If the canvas is decorative or too hard to describe through text, users should set alt text to `""`. + pub fn with_alt_text(mut self, alt_text: impl Into) -> Self { + self.alt_text = Some(alt_text.into()); + self + } +} + +// --- MARK: WIDGETMUT --- +impl Canvas { + /// Update the draw function + pub fn set_painter( + this: WidgetMut<'_, Self>, + draw: impl Fn(&mut Scene, Size) + Send + Sync + 'static, + ) { + Self::set_painter_arc(this, Arc::new(draw)); + } + + /// Update the draw function + pub fn set_painter_arc( + mut this: WidgetMut<'_, Self>, + draw: Arc, + ) { + this.widget.draw = draw; + this.ctx.request_render(); + } + + pub fn set_alt_text(mut this: WidgetMut<'_, Self>, alt_text: String) { + this.widget.alt_text = Some(alt_text); + this.ctx.request_accessibility_update(); + } + + pub fn remove_alt_text(mut this: WidgetMut<'_, Self>) { + this.widget.alt_text = None; + this.ctx.request_accessibility_update(); + } +} + +// --- MARK: IMPL WIDGET --- +impl Widget for Canvas { + fn on_pointer_event( + &mut self, + _ctx: &mut EventCtx, + _props: &mut PropertiesMut, + _event: &PointerEvent, + ) { + } + + fn accepts_pointer_interaction(&self) -> bool { + true + } + + fn on_text_event( + &mut self, + _ctx: &mut EventCtx, + _props: &mut PropertiesMut, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut EventCtx, + _props: &mut PropertiesMut, + _event: &AccessEvent, + ) { + } + + fn register_children(&mut self, _ctx: &mut RegisterCtx) {} + + fn update(&mut self, _ctx: &mut UpdateCtx, _props: &mut PropertiesMut, _event: &Update) {} + + fn layout( + &mut self, + _ctx: &mut LayoutCtx, + _props: &mut PropertiesMut, + bc: &BoxConstraints, + ) -> Size { + // use as much space as possible - caller can size it as necessary + bc.max() + } + + fn paint(&mut self, ctx: &mut PaintCtx, _props: &PropertiesRef, scene: &mut Scene) { + (self.draw)(scene, ctx.size()); + } + + fn accessibility_role(&self) -> Role { + Role::Canvas + } + + fn accessibility(&mut self, _ctx: &mut AccessCtx, _props: &PropertiesRef, node: &mut Node) { + if let Some(text) = &self.alt_text { + node.set_description(text.clone()); + } + } + + fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { + SmallVec::new() + } + + fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span { + trace_span!("Canvas", id = ctx.widget_id().trace()) + } + + fn get_debug_text(&self) -> Option { + self.alt_text.clone() + } +} + +// --- MARK: TESTS --- +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + use vello::kurbo::{Affine, BezPath, Stroke}; + use vello::peniko::{Color, Fill}; + + use super::*; + use crate::assert_render_snapshot; + use crate::testing::TestHarness; + + #[test] + fn simple_canvas() { + let canvas = Canvas::new(|scene, size| { + let scale = Affine::scale_non_uniform(size.width, size.height); + let mut path = BezPath::new(); + path.move_to((0.1, 0.1)); + path.line_to((0.9, 0.9)); + path.line_to((0.9, 0.1)); + path.close_path(); + path = scale * path; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::from_rgb8(100, 240, 150), + None, + &path, + ); + scene.stroke( + &Stroke::new(4.), + Affine::IDENTITY, + Color::from_rgb8(200, 140, 50), + None, + &path, + ); + }); + + let mut harness = TestHarness::create(canvas); + + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "hello"); + } +} diff --git a/masonry/src/widgets/mod.rs b/masonry/src/widgets/mod.rs index 9c666221c1..758bef4795 100644 --- a/masonry/src/widgets/mod.rs +++ b/masonry/src/widgets/mod.rs @@ -11,6 +11,7 @@ mod tests; mod align; mod button; +mod canvas; mod checkbox; mod flex; mod grid; @@ -31,6 +32,7 @@ mod zstack; pub use self::align::Align; pub use self::button::Button; +pub use self::canvas::Canvas; pub use self::checkbox::Checkbox; pub use self::flex::{Axis, CrossAxisAlignment, Flex, FlexParams, MainAxisAlignment}; pub use self::grid::{Grid, GridParams}; diff --git a/masonry/src/widgets/screenshots/masonry__widgets__canvas__tests__hello.png b/masonry/src/widgets/screenshots/masonry__widgets__canvas__tests__hello.png new file mode 100644 index 0000000000..86c6c982df --- /dev/null +++ b/masonry/src/widgets/screenshots/masonry__widgets__canvas__tests__hello.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7634b7801c3be71f13923bb9a77da9aeffe67eb3b042eab9ccffbd647655759 +size 11377 diff --git a/masonry/src/widgets/snapshots/masonry__widgets__canvas__tests__simple_canvas.snap b/masonry/src/widgets/snapshots/masonry__widgets__canvas__tests__simple_canvas.snap new file mode 100644 index 0000000000..2e20cf8589 --- /dev/null +++ b/masonry/src/widgets/snapshots/masonry__widgets__canvas__tests__simple_canvas.snap @@ -0,0 +1,5 @@ +--- +source: masonry/src/widgets/canvas.rs +expression: harness.root_widget() +--- +Canvas diff --git a/xilem/src/view/canvas.rs b/xilem/src/view/canvas.rs new file mode 100644 index 0000000000..61b6311e9b --- /dev/null +++ b/xilem/src/view/canvas.rs @@ -0,0 +1,92 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use masonry::widgets; +use vello::Scene; +use vello::kurbo::Size; + +use crate::core::{DynMessage, Mut, ViewMarker}; +use crate::{MessageResult, Pod, View, ViewCtx, ViewId}; + +/// A non-interactive text element. +/// # Example +/// +/// ```ignore +/// use xilem::palette; +/// use xilem::view::label; +/// use masonry::TextAlignment; +/// use masonry::parley::fontique; +/// +/// label("Text example.") +/// .brush(palette::css::RED) +/// .alignment(TextAlignment::Middle) +/// .text_size(24.0) +/// .weight(FontWeight::BOLD) +/// .with_font(fontique::GenericFamily::Serif) +/// ``` +pub fn canvas(draw: impl Fn(&mut Scene, Size) + Send + Sync + 'static) -> Canvas { + Canvas { + draw: Arc::new(draw), + alt_text: None, + } +} + +/// Create a canvas view. +#[must_use = "View values do nothing unless provided to Xilem."] +pub struct Canvas { + draw: Arc, + alt_text: Option, +} + +impl Canvas { + /// Sets alt text for the contents of the canvas. + /// + /// Users are strongly encouraged to provide alt text for accessibility tools + /// to use. + pub fn alt_text(mut self, alt_text: String) -> Self { + self.alt_text = Some(alt_text); + self + } +} + +impl ViewMarker for Canvas {} +impl View for Canvas { + type Element = Pod; + type ViewState = (); + + fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { + let widget = widgets::Canvas::from_arc(self.draw.clone()); + + let widget_pod = ctx.new_pod(widget); + (widget_pod, ()) + } + + fn rebuild( + &self, + prev: &Self, + (): &mut Self::ViewState, + _ctx: &mut ViewCtx, + element: Mut, + ) { + if !Arc::ptr_eq(&self.draw, &prev.draw) { + widgets::Canvas::set_painter_arc(element, self.draw.clone()); + } + } + + fn teardown(&self, (): &mut Self::ViewState, _: &mut ViewCtx, _: Mut) {} + + fn message( + &self, + (): &mut Self::ViewState, + _id_path: &[ViewId], + message: DynMessage, + _app_state: &mut State, + ) -> MessageResult { + tracing::error!( + "Message arrived in Canvas::message, but Canvas doesn't consume any messages, this is a bug" + ); + MessageResult::Stale(message) + } +} diff --git a/xilem/src/view/mod.rs b/xilem/src/view/mod.rs index 45cc038b7b..e1c0cd4fda 100644 --- a/xilem/src/view/mod.rs +++ b/xilem/src/view/mod.rs @@ -12,6 +12,9 @@ pub use worker::*; mod button; pub use button::*; +mod canvas; +pub use canvas::*; + mod checkbox; pub use checkbox::*;