diff --git a/masonry/src/widget/mod.rs b/masonry/src/widget/mod.rs index fef135632..34def0863 100644 --- a/masonry/src/widget/mod.rs +++ b/masonry/src/widget/mod.rs @@ -20,6 +20,7 @@ mod flex; mod image; mod label; mod portal; +mod progress_bar; mod prose; mod root_widget; mod scroll_bar; @@ -37,6 +38,7 @@ pub use checkbox::Checkbox; pub use flex::{Axis, CrossAxisAlignment, Flex, FlexParams, MainAxisAlignment}; pub use label::{Label, LineBreaking}; pub use portal::Portal; +pub use progress_bar::ProgressBar; pub use prose::Prose; pub use root_widget::RootWidget; pub use scroll_bar::ScrollBar; diff --git a/masonry/src/widget/progress_bar.rs b/masonry/src/widget/progress_bar.rs new file mode 100644 index 000000000..2c85b1ba3 --- /dev/null +++ b/masonry/src/widget/progress_bar.rs @@ -0,0 +1,281 @@ +// Copyright 2019 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A progress bar widget. + +use crate::Point; +use accesskit::Role; +use smallvec::{smallvec, SmallVec}; +use tracing::{trace, trace_span, Span}; +use vello::Scene; + +use crate::kurbo::Size; +use crate::paint_scene_helpers::{fill_lin_gradient, stroke, UnitPoint}; +use crate::text::TextLayout; +use crate::widget::WidgetMut; +use crate::{ + theme, AccessCtx, AccessEvent, ArcStr, BoxConstraints, EventCtx, LayoutCtx, LifeCycle, + LifeCycleCtx, PaintCtx, PointerEvent, StatusChange, TextEvent, Widget, WidgetId, +}; + +/// A progress bar +pub struct ProgressBar { + /// A value in the range `[0, 1]` inclusive, where 0 is 0% and 1 is 100% complete. + /// + /// `None` variant can be used to show a progress bar without a percentage. + /// It is also used if an invalid float (outside of [0, 1]) is passed. + progress: Option, + label: TextLayout, +} + +impl ProgressBar { + /// Create a new `ProgressBar`. + /// + /// `progress` is a number between 0 and 1 inclusive. If it is `NaN`, then an + /// indefinite progress bar will be shown. + /// Otherwise, the input will be clamped to [0, 1]. + pub fn new(progress: Option) -> Self { + let mut out = Self::new_indefinite(); + out.set_progress(progress); + out + } + + fn new_indefinite() -> Self { + Self { + progress: None, + label: TextLayout::new("".into(), crate::theme::TEXT_SIZE_NORMAL as f32), + } + } + + fn set_progress(&mut self, mut progress: Option) { + clamp_progress(&mut progress); + // check to see if we can avoid doing work + if self.progress != progress { + self.progress = progress; + self.update_text(); + } + } + + /// Updates the text layout with the current part-complete value + fn update_text(&mut self) { + self.label.set_text(self.value()); + } + + fn value(&self) -> ArcStr { + if let Some(value) = self.progress { + format!("{:.0}%", value * 100.).into() + } else { + "".into() + } + } + + fn value_accessibility(&self) -> Box { + if let Some(value) = self.progress { + format!("{:.0}%", value * 100.).into() + } else { + "progress unspecified".into() + } + } +} + +// --- MARK: WIDGETMUT --- +impl WidgetMut<'_, ProgressBar> { + pub fn set_progress(&mut self, progress: Option) { + self.widget.set_progress(progress); + self.ctx.request_layout(); + self.ctx.request_accessibility_update(); + } +} + +/// Helper to ensure progress is either a number between [0, 1] inclusive, or `None`. +/// +/// NaNs are converted to `None`. +fn clamp_progress(progress: &mut Option) { + if let Some(value) = progress { + if value.is_nan() { + *progress = None; + } else { + *progress = Some(value.clamp(0., 1.)); + } + } +} + +// --- MARK: IMPL WIDGET --- +impl Widget for ProgressBar { + // pointer events unhandled for now + fn on_pointer_event(&mut self, _ctx: &mut EventCtx, _event: &PointerEvent) {} + + fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {} + + // access events unhandled for now + fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {} + + fn on_status_change(&mut self, ctx: &mut LifeCycleCtx, _event: &StatusChange) { + ctx.request_paint(); + } + + fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle) {} + + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { + const DEFAULT_WIDTH: f64 = 400.; + + if self.label.needs_rebuild() { + let (font_ctx, layout_ctx) = ctx.text_contexts(); + self.label.rebuild(font_ctx, layout_ctx); + } + let label_size = self.label.size(); + + let desired_size = Size::new( + DEFAULT_WIDTH.max(label_size.width), + crate::theme::BASIC_WIDGET_HEIGHT.max(label_size.height), + ); + let our_size = bc.constrain(desired_size); + trace!("Computed layout: size={}", our_size); + our_size + } + + fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { + let border_width = 1.; + + if self.label.needs_rebuild() { + debug_panic!("Called ProgressBar paint before layout"); + } + + let rect = ctx + .size() + .to_rect() + .inset(-border_width / 2.) + .to_rounded_rect(2.); + + fill_lin_gradient( + scene, + &rect, + [theme::BACKGROUND_LIGHT, theme::BACKGROUND_DARK], + UnitPoint::TOP, + UnitPoint::BOTTOM, + ); + + stroke(scene, &rect, theme::BORDER_DARK, border_width); + + let progress_rect_size = Size::new( + ctx.size().width * self.progress.unwrap_or(1.), + ctx.size().height, + ); + let progress_rect = progress_rect_size + .to_rect() + .inset(-border_width / 2.) + .to_rounded_rect(2.); + + fill_lin_gradient( + scene, + &progress_rect, + [theme::PRIMARY_LIGHT, theme::PRIMARY_DARK], + UnitPoint::TOP, + UnitPoint::BOTTOM, + ); + stroke(scene, &progress_rect, theme::BORDER_DARK, border_width); + + // center text + let widget_size = ctx.size(); + let label_size = self.label.size(); + let text_pos = Point::new( + ((widget_size.width - label_size.width) * 0.5).max(0.), + ((widget_size.height - label_size.height) * 0.5).max(0.), + ); + self.label.draw(scene, text_pos); + } + + fn accessibility_role(&self) -> Role { + Role::ProgressIndicator + } + + fn accessibility(&mut self, ctx: &mut AccessCtx) { + ctx.current_node().set_value(self.value_accessibility()); + if let Some(value) = self.progress { + ctx.current_node().set_numeric_value(value * 100.0); + } + } + + fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { + smallvec![] + } + + fn make_trace_span(&self) -> Span { + trace_span!("ProgressBar") + } + + fn get_debug_text(&self) -> Option { + Some(self.value_accessibility().into()) + } +} + +// --- MARK: TESTS --- +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + + use super::*; + use crate::assert_render_snapshot; + use crate::testing::{widget_ids, TestHarness, TestWidgetExt}; + + #[test] + fn indeterminate_progressbar() { + let [progressbar_id] = widget_ids(); + let widget = ProgressBar::new(None).with_id(progressbar_id); + + let mut harness = TestHarness::create(widget); + + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "indeterminate_progressbar"); + } + + #[test] + fn _0_percent_progressbar() { + let [_0percent] = widget_ids(); + + let widget = ProgressBar::new(Some(0.)).with_id(_0percent); + let mut harness = TestHarness::create(widget); + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "0_percent_progressbar"); + } + + #[test] + fn _25_percent_progressbar() { + let [_25percent] = widget_ids(); + + let widget = ProgressBar::new(Some(0.25)).with_id(_25percent); + let mut harness = TestHarness::create(widget); + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "25_percent_progressbar"); + } + + #[test] + fn _50_percent_progressbar() { + let [_50percent] = widget_ids(); + + let widget = ProgressBar::new(Some(0.5)).with_id(_50percent); + let mut harness = TestHarness::create(widget); + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "50_percent_progressbar"); + } + + #[test] + fn _75_percent_progressbar() { + let [_75percent] = widget_ids(); + + let widget = ProgressBar::new(Some(0.75)).with_id(_75percent); + let mut harness = TestHarness::create(widget); + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "75_percent_progressbar"); + } + + #[test] + fn _100_percent_progressbar() { + let [_100percent] = widget_ids(); + + let widget = ProgressBar::new(Some(1.)).with_id(_100percent); + let mut harness = TestHarness::create(widget); + assert_debug_snapshot!(harness.root_widget()); + assert_render_snapshot!(harness, "100_percent_progressbar"); + } +} diff --git a/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__0_percent_progressbar.png b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__0_percent_progressbar.png new file mode 100644 index 000000000..2e4055d64 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__0_percent_progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cd0c911733321222e8fe68360a2570a2843dfd9c6d6722852e5cbc04aa59f82 +size 5158 diff --git a/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__100_percent_progressbar.png b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__100_percent_progressbar.png new file mode 100644 index 000000000..c31d3a9db --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__100_percent_progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aed3d622ba753a6f9e54d66bce387ed3a3b60fa534da4bcdc37134d7c2549a80 +size 5553 diff --git a/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__25_percent_progressbar.png b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__25_percent_progressbar.png new file mode 100644 index 000000000..237252a34 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__25_percent_progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f09e9fefc145cf0b2c9cd9bac9416f15d70122187d3dd2e94f53096aff65efd +size 5886 diff --git a/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__50_percent_progressbar.png b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__50_percent_progressbar.png new file mode 100644 index 000000000..dbbba09db --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__50_percent_progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddc713af21f3ef041f68340d60e436cc72549a44f635a833bb74ec0b227e0409 +size 5889 diff --git a/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__75_percent_progressbar.png b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__75_percent_progressbar.png new file mode 100644 index 000000000..dcc1d8aba --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__75_percent_progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e17e08d69d8bb7a3e1075f106a3d0d74cef347cfecda307671c3309eb68f3d4f +size 5491 diff --git a/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__indeterminate_progressbar.png b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__indeterminate_progressbar.png new file mode 100644 index 000000000..c2270297e --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__progress_bar__tests__indeterminate_progressbar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:552d71b4588d1492e0a773c9bdb554dc331ee18774bd3359065e736074a0160b +size 4823 diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___0_percent_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___0_percent_progressbar.snap new file mode 100644 index 000000000..2745b0546 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___0_percent_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar<0%>, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___100_percent_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___100_percent_progressbar.snap new file mode 100644 index 000000000..d3e490106 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___100_percent_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar<100%>, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___25_percent_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___25_percent_progressbar.snap new file mode 100644 index 000000000..533d95b9f --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___25_percent_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar<25%>, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___50_percent_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___50_percent_progressbar.snap new file mode 100644 index 000000000..2b4cc09e0 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___50_percent_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar<50%>, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___75_percent_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___75_percent_progressbar.snap new file mode 100644 index 000000000..d0e2ff527 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests___75_percent_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar<75%>, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests__indeterminate_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests__indeterminate_progressbar.snap new file mode 100644 index 000000000..2b83661cc --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests__indeterminate_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar, +) diff --git a/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests__part_progressbar.snap b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests__part_progressbar.snap new file mode 100644 index 000000000..2745b0546 --- /dev/null +++ b/masonry/src/widget/snapshots/masonry__widget__progress_bar__tests__part_progressbar.snap @@ -0,0 +1,7 @@ +--- +source: masonry/src/widget/progress_bar.rs +expression: harness.root_widget() +--- +SizedBox( + ProgressBar<0%>, +) diff --git a/xilem/examples/widgets.rs b/xilem/examples/widgets.rs new file mode 100644 index 000000000..fb168de26 --- /dev/null +++ b/xilem/examples/widgets.rs @@ -0,0 +1,94 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A widget gallery for xilem/masonry + +use masonry::dpi::LogicalSize; +use masonry::event_loop_runner::{EventLoop, EventLoopBuilder}; +use winit::error::EventLoopError; +use winit::window::Window; +use xilem::view::{button, checkbox, flex, label, progress_bar, FlexSpacer}; +use xilem::{WidgetView, Xilem}; + +/// The state of the entire application. +/// +/// This is owned by Xilem, used to construct the view tree, and updated by event handlers. +struct WidgetGallery { + progress: Option, +} + +fn app_logic(data: &mut WidgetGallery) -> impl WidgetView { + flex(( + label("this 'widgets' example currently only has 1 widget"), + FlexSpacer::Flex(1.), + progress_bar(data.progress), + checkbox( + "set indeterminate progress", + data.progress.is_none(), + |state: &mut WidgetGallery, checked| { + if checked { + state.progress = None; + } else { + state.progress = Some(0.5); + } + }, + ), + button("change progress", |state: &mut WidgetGallery| { + match state.progress { + Some(ref mut v) => *v = (*v + 0.1).rem_euclid(1.), + None => state.progress = Some(0.5), + } + }), + FlexSpacer::Flex(1.), + )) +} + +fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> { + let data = WidgetGallery { + progress: Some(0.5), + }; + + let app = Xilem::new(data, app_logic); + let min_window_size = LogicalSize::new(300., 200.); + let window_size = LogicalSize::new(450., 300.); + let window_attributes = Window::default_attributes() + .with_title("Xilem Widgets") + .with_resizable(true) + .with_min_inner_size(min_window_size) + .with_inner_size(window_size); + app.run_windowed_in(event_loop, window_attributes)?; + Ok(()) +} + +#[cfg(not(target_os = "android"))] +#[allow(dead_code)] +// This is treated as dead code by the Android version of the example, but is actually live +// This hackery is required because Cargo doesn't care to support this use case, of one +// example which works across Android and desktop +fn main() -> Result<(), EventLoopError> { + run(EventLoop::with_user_event()) +} + +// Boilerplate code for android: Identical across all applications + +#[cfg(target_os = "android")] +// Safety: We are following `android_activity`'s docs here +// We believe that there are no other declarations using this name in the compiled objects here +#[allow(unsafe_code)] +#[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"); +} + +// TODO: This is a hack because of how we handle our examples in Cargo.toml +// Ideally, we change Cargo to be more sensible here? +#[cfg(target_os = "android")] +#[allow(dead_code)] +fn main() { + unreachable!() +} diff --git a/xilem/src/view/mod.rs b/xilem/src/view/mod.rs index 9cafb2016..5b14f8d5a 100644 --- a/xilem/src/view/mod.rs +++ b/xilem/src/view/mod.rs @@ -25,6 +25,9 @@ pub use label::*; mod variable_label; pub use variable_label::*; +mod progress_bar; +pub use progress_bar::*; + mod prose; pub use prose::*; diff --git a/xilem/src/view/progress_bar.rs b/xilem/src/view/progress_bar.rs new file mode 100644 index 000000000..85c88f9b1 --- /dev/null +++ b/xilem/src/view/progress_bar.rs @@ -0,0 +1,59 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use masonry::widget; +use xilem_core::{Mut, ViewMarker}; + +use crate::{MessageResult, Pod, View, ViewCtx, ViewId}; + +pub fn progress_bar(progress: Option) -> ProgressBar { + ProgressBar { progress } +} + +pub struct ProgressBar { + progress: Option, +} + +impl ViewMarker for ProgressBar {} +impl View for ProgressBar { + type Element = Pod; + type ViewState = (); + + fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { + ctx.with_leaf_action_widget(|_| Pod::new(masonry::widget::ProgressBar::new(self.progress))) + } + + fn rebuild<'el>( + &self, + prev: &Self, + (): &mut Self::ViewState, + ctx: &mut ViewCtx, + mut element: Mut<'el, Self::Element>, + ) -> Mut<'el, Self::Element> { + if prev.progress != self.progress { + element.set_progress(self.progress); + ctx.mark_changed(); + } + element + } + + fn teardown( + &self, + (): &mut Self::ViewState, + ctx: &mut ViewCtx, + element: Mut<'_, Self::Element>, + ) { + ctx.teardown_leaf(element); + } + + fn message( + &self, + (): &mut Self::ViewState, + _id_path: &[ViewId], + message: xilem_core::DynMessage, + _app_state: &mut State, + ) -> MessageResult { + tracing::error!("Message arrived in ProgressBar::message, but ProgressBar doesn't consume any messages, this is a bug"); + MessageResult::Stale(message) + } +}