feat: add progress bar widget (#513)

@PoignardAzur I wanted to have a play with masonry, so I had a go at
building a progress bar. I've made a PR in case you want it, but I won't
be offended if you close the PR. I'm happy to make changes if you see
anything you'd like to change.

---------

Co-authored-by: Olivier FAURE <couteaubleu@gmail.com>
Co-authored-by: jaredoconnell <jared.oc321@gmail.com>
This commit is contained in:
Richard Dodd (dodj) 2024-08-30 22:15:34 +01:00 committed by GitHub
parent 6c4951635d
commit a1c7d74257
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 506 additions and 0 deletions

View File

@ -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;

View File

@ -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<f64>,
label: TextLayout<ArcStr>,
}
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<f64>) -> 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<f64>) {
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<str> {
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<f64>) {
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<f64>) {
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<String> {
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");
}
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7cd0c911733321222e8fe68360a2570a2843dfd9c6d6722852e5cbc04aa59f82
size 5158

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aed3d622ba753a6f9e54d66bce387ed3a3b60fa534da4bcdc37134d7c2549a80
size 5553

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f09e9fefc145cf0b2c9cd9bac9416f15d70122187d3dd2e94f53096aff65efd
size 5886

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ddc713af21f3ef041f68340d60e436cc72549a44f635a833bb74ec0b227e0409
size 5889

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e17e08d69d8bb7a3e1075f106a3d0d74cef347cfecda307671c3309eb68f3d4f
size 5491

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:552d71b4588d1492e0a773c9bdb554dc331ee18774bd3359065e736074a0160b
size 4823

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<0%>,
)

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<100%>,
)

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<25%>,
)

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<50%>,
)

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<75%>,
)

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<progress unspecified>,
)

View File

@ -0,0 +1,7 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
---
SizedBox(
ProgressBar<0%>,
)

94
xilem/examples/widgets.rs Normal file
View File

@ -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<f64>,
}
fn app_logic(data: &mut WidgetGallery) -> impl WidgetView<WidgetGallery> {
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!()
}

View File

@ -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::*;

View File

@ -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<f64>) -> ProgressBar {
ProgressBar { progress }
}
pub struct ProgressBar {
progress: Option<f64>,
}
impl ViewMarker for ProgressBar {}
impl<State, Action> View<State, Action, ViewCtx> for ProgressBar {
type Element = Pod<widget::ProgressBar>;
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<Action> {
tracing::error!("Message arrived in ProgressBar::message, but ProgressBar doesn't consume any messages, this is a bug");
MessageResult::Stale(message)
}
}