mirror of https://github.com/linebender/xilem
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:
parent
6c4951635d
commit
a1c7d74257
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7cd0c911733321222e8fe68360a2570a2843dfd9c6d6722852e5cbc04aa59f82
|
||||
size 5158
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aed3d622ba753a6f9e54d66bce387ed3a3b60fa534da4bcdc37134d7c2549a80
|
||||
size 5553
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f09e9fefc145cf0b2c9cd9bac9416f15d70122187d3dd2e94f53096aff65efd
|
||||
size 5886
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ddc713af21f3ef041f68340d60e436cc72549a44f635a833bb74ec0b227e0409
|
||||
size 5889
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e17e08d69d8bb7a3e1075f106a3d0d74cef347cfecda307671c3309eb68f3d4f
|
||||
size 5491
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:552d71b4588d1492e0a773c9bdb554dc331ee18774bd3359065e736074a0160b
|
||||
size 4823
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<0%>,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<100%>,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<25%>,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<50%>,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<75%>,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<progress unspecified>,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
source: masonry/src/widget/progress_bar.rs
|
||||
expression: harness.root_widget()
|
||||
---
|
||||
SizedBox(
|
||||
ProgressBar<0%>,
|
||||
)
|
|
@ -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!()
|
||||
}
|
|
@ -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::*;
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue