masonry 0.4.0

Traits and types of the Masonry toolkit.
Documentation
// Copyright 2019 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0

//! A progress bar widget.

use std::any::TypeId;

use accesskit::{Node, Role};
use masonry_core::core::{NewWidget, Properties};
use tracing::{Span, trace_span};
use vello::Scene;
use vello::kurbo::{Point, Size};

use crate::core::{
    AccessCtx, ArcStr, BoxConstraints, ChildrenIds, LayoutCtx, NoAction, PaintCtx, PropertiesMut,
    PropertiesRef, RegisterCtx, Update, UpdateCtx, Widget, WidgetId, WidgetMut, WidgetPod,
};
use crate::properties::LineBreaking;
use crate::properties::{Background, BarColor, BorderColor, BorderWidth, CornerRadius};
use crate::util::{fill, include_screenshot, stroke};
use crate::widgets::Label;

// TODO - NaN probably shouldn't be a meaningful value in our API.

/// A progress bar.
///
#[doc = include_screenshot!("progress_bar_25_percent.png", "25% 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: WidgetPod<Label>,
}

impl ProgressBar {
    /// Create a new `ProgressBar`.
    ///
    /// The progress value will be clamped to [0, 1].
    ///
    /// A `None` value (or NaN) will show an indeterminate progress bar.
    pub fn new(progress: Option<f64>) -> Self {
        let progress = clamp_progress(progress);
        let label_props = Properties::one(LineBreaking::Overflow);
        let label =
            NewWidget::new_with_props(Label::new(Self::value(progress)), label_props).to_pod();
        Self { progress, label }
    }

    fn value_accessibility(&self) -> Box<str> {
        if let Some(value) = self.progress {
            format!("{:.0}%", value * 100.).into()
        } else {
            "progress unspecified".into()
        }
    }

    fn value(progress: Option<f64>) -> ArcStr {
        if let Some(value) = progress {
            format!("{:.0}%", value * 100.).into()
        } else {
            "".into()
        }
    }
}

// --- MARK: WIDGETMUT
impl ProgressBar {
    /// Set the progress displayed by the bar.
    ///
    /// The progress value will be clamped to [0, 1].
    ///
    /// A `None` value (or NaN) will show an indeterminate progress bar.
    pub fn set_progress(this: &mut WidgetMut<'_, Self>, progress: Option<f64>) {
        let progress = clamp_progress(progress);
        let progress_changed = this.widget.progress != progress;
        if progress_changed {
            this.widget.progress = progress;
            let mut label = this.ctx.get_mut(&mut this.widget.label);
            Label::set_text(&mut label, Self::value(progress));
        }
        this.ctx.request_layout();
        this.ctx.request_render();
    }
}

/// Helper to ensure progress is either a number between [0, 1] inclusive, or `None`.
///
/// NaNs are converted to `None`.
fn clamp_progress(progress: Option<f64>) -> Option<f64> {
    let progress = progress?;
    if progress.is_nan() {
        None
    } else {
        Some(progress.clamp(0., 1.))
    }
}

// --- MARK: IMPL WIDGET
impl Widget for ProgressBar {
    type Action = NoAction;

    fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) {
        ctx.register_child(&mut self.label);
    }

    fn property_changed(&mut self, ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
        BorderWidth::prop_changed(ctx, property_type);
        CornerRadius::prop_changed(ctx, property_type);
        Background::prop_changed(ctx, property_type);
        BarColor::prop_changed(ctx, property_type);
        BorderColor::prop_changed(ctx, property_type);
    }

    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 {
        const DEFAULT_WIDTH: f64 = 400.;
        // TODO: Clearer constraints here
        let label_size = ctx.run_layout(&mut self.label, &bc.loosen());
        let desired_size = Size::new(
            DEFAULT_WIDTH.max(label_size.width),
            crate::theme::BASIC_WIDGET_HEIGHT.max(label_size.height),
        );
        let final_size = bc.constrain(desired_size);

        // center text
        let text_pos = Point::new(
            ((final_size.width - label_size.width) * 0.5).max(0.),
            ((final_size.height - label_size.height) * 0.5).max(0.),
        );
        ctx.place_child(&mut self.label, text_pos);
        final_size
    }

    fn paint(&mut self, ctx: &mut PaintCtx<'_>, props: &PropertiesRef<'_>, scene: &mut Scene) {
        let border_width = props.get::<BorderWidth>();
        let border_radius = props.get::<CornerRadius>();
        let bg = props.get::<Background>();
        let bar_color = props.get::<BarColor>();
        let border_color = props.get::<BorderColor>();

        let bg_rect = border_width.bg_rect(ctx.size(), border_radius);
        let border_rect = border_width.border_rect(ctx.size(), border_radius);

        let progress_rect_size = Size::new(
            ctx.size().width * self.progress.unwrap_or(1.),
            ctx.size().height,
        );
        let progress_rect = border_width.bg_rect(progress_rect_size, border_radius);

        let brush = bg.get_peniko_brush_for_rect(bg_rect.rect());
        fill(scene, &bg_rect, &brush);
        fill(scene, &progress_rect, bar_color.0);

        stroke(scene, &border_rect, border_color.color, border_width.width);
    }

    fn accessibility_role(&self) -> Role {
        Role::ProgressIndicator
    }

    fn accessibility(
        &mut self,
        _ctx: &mut AccessCtx<'_>,
        _props: &PropertiesRef<'_>,
        node: &mut Node,
    ) {
        node.set_min_numeric_value(0.0);
        node.set_max_numeric_value(1.0);
        if let Some(value) = self.progress {
            node.set_numeric_value(value);
        }
    }

    fn children_ids(&self) -> ChildrenIds {
        ChildrenIds::from_slice(&[self.label.id()])
    }

    fn make_trace_span(&self, id: WidgetId) -> Span {
        trace_span!("ProgressBar", id = id.trace())
    }

    fn get_debug_text(&self) -> Option<String> {
        Some(self.value_accessibility().into())
    }
}

// --- MARK: TESTS
#[cfg(test)]
mod tests {
    use masonry_core::core::NewWidget;

    use super::*;
    use crate::testing::{TestHarness, assert_render_snapshot};
    use crate::theme::default_property_set;
    use crate::{core::Properties, palette};

    #[test]
    fn indeterminate_progressbar() {
        let widget = NewWidget::new(ProgressBar::new(None));

        let window_size = Size::new(150.0, 60.0);
        let mut harness =
            TestHarness::create_with_size(default_property_set(), widget, window_size);

        assert_render_snapshot!(harness, "progress_bar_indeterminate");
    }

    #[test]
    fn _0_percent_progressbar() {
        let widget = NewWidget::new(ProgressBar::new(Some(0.)));
        let window_size = Size::new(150.0, 60.0);
        let mut harness =
            TestHarness::create_with_size(default_property_set(), widget, window_size);

        assert_render_snapshot!(harness, "progress_bar_0_percent");
    }

    #[test]
    fn _25_percent_progressbar() {
        let widget = NewWidget::new(ProgressBar::new(Some(0.25)));
        let window_size = Size::new(150.0, 60.0);
        let mut harness =
            TestHarness::create_with_size(default_property_set(), widget, window_size);

        assert_render_snapshot!(harness, "progress_bar_25_percent");
    }

    #[test]
    fn _50_percent_progressbar() {
        let widget = NewWidget::new(ProgressBar::new(Some(0.5)));
        let window_size = Size::new(150.0, 60.0);
        let mut harness =
            TestHarness::create_with_size(default_property_set(), widget, window_size);

        assert_render_snapshot!(harness, "progress_bar_50_percent");
    }

    #[test]
    fn _75_percent_progressbar() {
        let widget = NewWidget::new(ProgressBar::new(Some(0.75)));
        let window_size = Size::new(150.0, 60.0);
        let mut harness =
            TestHarness::create_with_size(default_property_set(), widget, window_size);

        assert_render_snapshot!(harness, "progress_bar_75_percent");
    }

    #[test]
    fn _100_percent_progressbar() {
        let widget = NewWidget::new(ProgressBar::new(Some(1.)));
        let window_size = Size::new(150.0, 60.0);
        let mut harness =
            TestHarness::create_with_size(default_property_set(), widget, window_size);

        assert_render_snapshot!(harness, "progress_bar_100_percent");
    }

    #[test]
    fn edit_progressbar() {
        let image_1 = {
            let bar = ProgressBar::new(Some(0.5))
                .with_props(Properties::new().with(BarColor(palette::css::PURPLE)));

            let mut harness =
                TestHarness::create_with_size(default_property_set(), bar, Size::new(60.0, 20.0));

            harness.render()
        };

        let image_2 = {
            let bar = NewWidget::new(ProgressBar::new(None));

            let mut harness =
                TestHarness::create_with_size(default_property_set(), bar, Size::new(60.0, 20.0));

            harness.edit_root_widget(|mut bar| {
                ProgressBar::set_progress(&mut bar, Some(0.5));
                bar.insert_prop(BarColor(palette::css::PURPLE));
            });

            harness.render()
        };

        // We don't use assert_eq because we don't want rich assert
        assert!(image_1 == image_2);
    }
}