druid 0.8.2

Data-oriented Rust UI design toolkit.
Documentation
// Copyright 2020 The Druid Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::commands::SCROLL_TO_VIEW;
use crate::contexts::ChangeCtx;
use crate::debug_state::DebugState;
use crate::kurbo::{Point, Rect, Size, Vec2};
use crate::widget::prelude::*;
use crate::widget::Axis;
use crate::{Data, InternalLifeCycle, WidgetPod};
use tracing::{info, instrument, trace, warn};

/// Represents the size and position of a rectangular "viewport" into a larger area.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct Viewport {
    /// The size of the area that we have a viewport into.
    pub content_size: Size,
    /// The origin of the view rectangle, relative to the content.
    pub view_origin: Point,
    /// The size of the view rectangle.
    pub view_size: Size,
}

impl Viewport {
    /// The view rectangle.
    pub fn view_rect(&self) -> Rect {
        Rect::from_origin_size(self.view_origin, self.view_size)
    }

    /// Tries to find a position for the view rectangle that is contained in the content rectangle.
    ///
    /// If the supplied origin is good, returns it; if it isn't, we try to return the nearest
    /// origin that would make the view rectangle contained in the content rectangle. (This will
    /// fail if the content is smaller than the view, and we return `0.0` in each dimension where
    /// the content is smaller.)
    pub fn clamp_view_origin(&self, origin: Point) -> Point {
        #![allow(clippy::manual_clamp)]
        let x = origin
            .x
            .min(self.content_size.width - self.view_size.width)
            .max(0.0);
        let y = origin
            .y
            .min(self.content_size.height - self.view_size.height)
            .max(0.0);
        Point::new(x, y)
    }

    fn sanitize_view_origin(&mut self) {
        self.view_origin = self.clamp_view_origin(self.view_origin);
    }

    /// Changes the viewport offset by `delta`, while trying to keep the view rectangle inside the
    /// content rectangle.
    ///
    /// Returns true if the offset actually changed. Even if `delta` is non-zero, the offset might
    /// not change. For example, if you try to move the viewport down but it is already at the
    /// bottom of the child widget, then the offset will not change and this function will return
    /// false.
    pub fn pan_by(&mut self, delta: Vec2) -> bool {
        self.pan_to(self.view_origin + delta)
    }

    /// Sets the viewport origin to `pos`, while trying to keep the view rectangle inside the
    /// content rectangle.
    ///
    /// Returns true if the position changed. Note that the valid values for the viewport origin
    /// are constrained by the size of the child, and so the origin might not get set to exactly
    /// `pos`.
    pub fn pan_to(&mut self, pos: Point) -> bool {
        let new_origin = self.clamp_view_origin(pos);
        if (new_origin - self.view_origin).hypot2() > 1e-12 {
            self.view_origin = new_origin;
            true
        } else {
            false
        }
    }

    /// Sets the component selected by `axis` of viewport origin to `pos`, while trying to keep the
    /// view rectangle inside the content rectangle.
    ///
    /// Returns `true` if the position changed. Note that the valid values for the viewport origin
    /// are constrained by the size of the child, and so the origin might not get set to exactly
    /// `pos`.
    pub fn pan_to_on_axis(&mut self, axis: Axis, pos: f64) -> bool {
        self.pan_to(Point::from(
            axis.pack(pos, axis.minor_pos(self.view_origin)),
        ))
    }

    /// Pan the smallest distance that makes the target [`Rect`] visible.
    ///
    /// If the target rect is larger than viewport size, we will prioritize
    /// the region of the target closest to its origin.
    pub fn pan_to_visible(&mut self, rect: Rect) -> bool {
        /// Given a position and the min and max edges of an axis,
        /// return a delta by which to adjust that axis such that the value
        /// falls between its edges.
        ///
        /// if the value already falls between the two edges, return 0.0.
        fn closest_on_axis(val: f64, min: f64, max: f64) -> f64 {
            assert!(min <= max);
            if val > min && val < max {
                0.0
            } else if val <= min {
                val - min
            } else {
                val - max
            }
        }

        // clamp the target region size to our own size.
        // this means we will show the portion of the target region that
        // includes the origin.
        let target_size = Size::new(
            rect.width().min(self.view_size.width),
            rect.height().min(self.view_size.height),
        );
        let rect = rect.with_size(target_size);

        let my_rect = self.view_rect();
        let x0 = closest_on_axis(rect.min_x(), my_rect.min_x(), my_rect.max_x());
        let x1 = closest_on_axis(rect.max_x(), my_rect.min_x(), my_rect.max_x());
        let y0 = closest_on_axis(rect.min_y(), my_rect.min_y(), my_rect.max_y());
        let y1 = closest_on_axis(rect.max_y(), my_rect.min_y(), my_rect.max_y());

        let delta_x = if x0.abs() > x1.abs() { x0 } else { x1 };
        let delta_y = if y0.abs() > y1.abs() { y0 } else { y1 };
        let new_origin = self.view_origin + Vec2::new(delta_x, delta_y);
        self.pan_to(new_origin)
    }

    /// The default handling of the [`SCROLL_TO_VIEW`] notification for a scrolling container.
    ///
    /// The [`SCROLL_TO_VIEW`] notification is sent when [`EventCtx::scroll_to_view`]
    /// or [`EventCtx::scroll_area_to_view`] are called.
    ///
    /// [`SCROLL_TO_VIEW`]: crate::commands::SCROLL_TO_VIEW
    pub fn default_scroll_to_view_handling(
        &mut self,
        ctx: &mut EventCtx,
        global_highlight_rect: Rect,
    ) -> bool {
        let mut viewport_changed = false;
        let global_content_offset = ctx.window_origin().to_vec2() - self.view_origin.to_vec2();
        let content_highlight_rect = global_highlight_rect - global_content_offset;

        if self
            .content_size
            .to_rect()
            .intersect(content_highlight_rect)
            != content_highlight_rect
        {
            warn!("tried to bring area outside of the content to view!");
        }

        if self.pan_to_visible(content_highlight_rect) {
            ctx.request_paint();
            viewport_changed = true;
        }
        // This is a new value since view_origin has changed in the meantime
        let global_content_offset = ctx.window_origin().to_vec2() - self.view_origin.to_vec2();
        ctx.submit_notification_without_warning(
            SCROLL_TO_VIEW.with(content_highlight_rect + global_content_offset),
        );
        viewport_changed
    }

    /// This method handles SCROLL_TO_VIEW by clipping the view_rect to the content rect.
    ///
    /// The [`SCROLL_TO_VIEW`] notification is sent when [`EventCtx::scroll_to_view`]
    /// or [`EventCtx::scroll_area_to_view`] are called.
    ///
    /// [`SCROLL_TO_VIEW`]: crate::commands::SCROLL_TO_VIEW
    pub fn fixed_scroll_to_view_handling(
        &self,
        ctx: &mut EventCtx,
        global_highlight_rect: Rect,
        source: WidgetId,
    ) {
        let global_viewport_rect = self.view_rect() + ctx.window_origin().to_vec2();
        let clipped_highlight_rect = global_highlight_rect.intersect(global_viewport_rect);

        if clipped_highlight_rect.area() > 0.0 {
            ctx.submit_notification_without_warning(SCROLL_TO_VIEW.with(clipped_highlight_rect));
        } else {
            info!("Hidden Widget({}) in unmanaged clip requested SCROLL_TO_VIEW. The request is ignored.", source.to_raw());
        }
    }
}

/// A widget exposing a rectangular view into its child, which can be used as a building block for
/// widgets that scroll their child.
pub struct ClipBox<T, W> {
    child: WidgetPod<T, W>,
    port: Viewport,
    constrain_horizontal: bool,
    constrain_vertical: bool,
    must_fill: bool,
    old_bc: BoxConstraints,
    old_size: Size,

    //This ClipBox is wrapped by a widget which manages the viewport_offset
    managed: bool,
}

impl<T, W> ClipBox<T, W> {
    /// Builder-style method for deciding whether to constrain the child vertically.
    ///
    /// The default is `false`.
    ///
    /// This setting affects how a `ClipBox` lays out its child.
    ///
    /// - When it is `false` (the default), the child does not receive any upper
    ///   bound on its height: the idea is that the child can be as tall as it
    ///   wants, and the viewport will somehow get moved around to see all of it.
    /// - When it is `true`, the viewport's maximum height will be passed down
    ///   as an upper bound on the height of the child, and the viewport will set
    ///   its own height to be the same as its child's height.
    pub fn constrain_vertical(mut self, constrain: bool) -> Self {
        self.constrain_vertical = constrain;
        self
    }

    /// Builder-style method for deciding whether to constrain the child horizontally.
    ///
    /// The default is `false`. See [`constrain_vertical`] for more details.
    ///
    /// [`constrain_vertical`]: ClipBox::constrain_vertical
    pub fn constrain_horizontal(mut self, constrain: bool) -> Self {
        self.constrain_horizontal = constrain;
        self
    }

    /// Builder-style method to set whether the child must fill the view.
    ///
    /// If `false` (the default) there is no minimum constraint on the child's
    /// size. If `true`, the child is passed the same minimum constraints as
    /// the `ClipBox`.
    pub fn content_must_fill(mut self, must_fill: bool) -> Self {
        self.must_fill = must_fill;
        self
    }

    /// Returns a reference to the child widget.
    pub fn child(&self) -> &W {
        self.child.widget()
    }

    /// Returns a mutable reference to the child widget.
    pub fn child_mut(&mut self) -> &mut W {
        self.child.widget_mut()
    }

    /// Returns a the viewport describing this `ClipBox`'s position.
    pub fn viewport(&self) -> Viewport {
        self.port
    }

    /// Returns the origin of the viewport rectangle.
    pub fn viewport_origin(&self) -> Point {
        self.port.view_origin
    }

    /// Returns the size of the rectangular viewport into the child widget.
    /// To get the position of the viewport, see [`viewport_origin`].
    ///
    /// [`viewport_origin`]: ClipBox::viewport_origin
    pub fn viewport_size(&self) -> Size {
        self.port.view_size
    }

    /// Returns the size of the child widget.
    pub fn content_size(&self) -> Size {
        self.port.content_size
    }

    /// Set whether to constrain the child horizontally.
    ///
    /// See [`constrain_vertical`] for more details.
    ///
    /// [`constrain_vertical`]: ClipBox::constrain_vertical
    pub fn set_constrain_horizontal(&mut self, constrain: bool) {
        self.constrain_horizontal = constrain;
    }

    /// Set whether to constrain the child vertically.
    ///
    /// See [`constrain_vertical`] for more details.
    ///
    /// [`constrain_vertical`]: ClipBox::constrain_vertical
    pub fn set_constrain_vertical(&mut self, constrain: bool) {
        self.constrain_vertical = constrain;
    }

    /// Set whether the child's size must be greater than or equal the size of
    /// the `ClipBox`.
    ///
    /// See [`content_must_fill`] for more details.
    ///
    /// [`content_must_fill`]: ClipBox::content_must_fill
    pub fn set_content_must_fill(&mut self, must_fill: bool) {
        self.must_fill = must_fill;
    }
}

impl<T, W: Widget<T>> ClipBox<T, W> {
    /// Creates a new `ClipBox` wrapping `child`.
    ///
    /// This method should only be used when creating your own widget, which uses `ClipBox`
    /// internally.
    ///
    /// `ClipBox` will forward [`SCROLL_TO_VIEW`] notifications to its parent unchanged.
    /// In this case the parent has to handle said notification itself. By default the `ClipBox`
    /// will filter out [`SCROLL_TO_VIEW`] notifications which refer to areas not visible.
    ///
    /// [`SCROLL_TO_VIEW`]: crate::commands::SCROLL_TO_VIEW
    pub fn managed(child: W) -> Self {
        ClipBox {
            child: WidgetPod::new(child),
            port: Default::default(),
            constrain_horizontal: false,
            constrain_vertical: false,
            must_fill: false,
            old_bc: BoxConstraints::tight(Size::ZERO),
            old_size: Size::ZERO,
            managed: true,
        }
    }

    /// Creates a new unmanaged `ClipBox` wrapping `child`.
    ///
    /// This method should be used when you are using `ClipBox` in the widget tree directly.
    pub fn unmanaged(child: W) -> Self {
        ClipBox {
            child: WidgetPod::new(child),
            port: Default::default(),
            constrain_horizontal: false,
            constrain_vertical: false,
            must_fill: false,
            old_bc: BoxConstraints::tight(Size::ZERO),
            old_size: Size::ZERO,
            managed: false,
        }
    }

    /// Pans by `delta` units.
    ///
    /// Returns `true` if the scroll offset has changed.
    pub fn pan_by<C: ChangeCtx>(&mut self, ctx: &mut C, delta: Vec2) -> bool {
        self.with_port(ctx, |_, port| {
            port.pan_by(delta);
        })
    }

    /// Pans the minimal distance to show the `region`.
    ///
    /// If the target region is larger than the viewport, we will display the
    /// portion that fits, prioritizing the portion closest to the origin.
    pub fn pan_to_visible<C: ChangeCtx>(&mut self, ctx: &mut C, region: Rect) -> bool {
        self.with_port(ctx, |_, port| {
            port.pan_to_visible(region);
        })
    }

    /// Pan to this position on a particular axis.
    ///
    /// Returns `true` if the scroll offset has changed.
    pub fn pan_to_on_axis<C: ChangeCtx>(&mut self, ctx: &mut C, axis: Axis, position: f64) -> bool {
        self.with_port(ctx, |_, port| {
            port.pan_to_on_axis(axis, position);
        })
    }

    /// Modify the `ClipBox`'s viewport rectangle with a closure.
    ///
    /// The provided callback function can modify its argument, and when it is
    /// done then this `ClipBox` will be modified to have the new viewport rectangle.
    pub fn with_port<C: ChangeCtx, F: FnOnce(&mut C, &mut Viewport)>(
        &mut self,
        ctx: &mut C,
        f: F,
    ) -> bool {
        f(ctx, &mut self.port);
        self.port.sanitize_view_origin();
        let new_content_origin = (Point::ZERO - self.port.view_origin).to_point();

        if new_content_origin != self.child.layout_rect().origin() {
            self.child.set_origin(ctx, new_content_origin);
            true
        } else {
            false
        }
    }
}

impl<T: Data, W: Widget<T>> Widget<T> for ClipBox<T, W> {
    #[instrument(name = "ClipBox", level = "trace", skip(self, ctx, event, data, env))]
    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
        if let Event::Notification(notification) = event {
            if let Some(global_highlight_rect) = notification.get(SCROLL_TO_VIEW) {
                if !self.managed {
                    // If the parent widget does not handle SCROLL_TO_VIEW notifications, we
                    // prevent unexpected behaviour, by clipping SCROLL_TO_VIEW notifications
                    // to this ClipBox's viewport.
                    ctx.set_handled();
                    self.with_port(ctx, |ctx, port| {
                        port.fixed_scroll_to_view_handling(
                            ctx,
                            *global_highlight_rect,
                            notification.source(),
                        );
                    });
                }
            }
        } else {
            self.child.event(ctx, event, data, env);
        }
    }

    #[instrument(name = "ClipBox", level = "trace", skip(self, ctx, event, data, env))]
    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
        match event {
            LifeCycle::ViewContextChanged(view_context) => {
                let mut view_context = *view_context;
                view_context.clip = view_context.clip.intersect(ctx.size().to_rect());
                let modified_event = LifeCycle::ViewContextChanged(view_context);
                self.child.lifecycle(ctx, &modified_event, data, env);
            }
            LifeCycle::Internal(InternalLifeCycle::RouteViewContextChanged(view_context)) => {
                let mut view_context = *view_context;
                view_context.clip = view_context.clip.intersect(ctx.size().to_rect());
                let modified_event =
                    LifeCycle::Internal(InternalLifeCycle::RouteViewContextChanged(view_context));
                self.child.lifecycle(ctx, &modified_event, data, env);
            }
            _ => {
                self.child.lifecycle(ctx, event, data, env);
            }
        }
    }

    #[instrument(
        name = "ClipBox",
        level = "trace",
        skip(self, ctx, _old_data, data, env)
    )]
    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {
        self.child.update(ctx, data, env);
    }

    #[instrument(name = "ClipBox", level = "trace", skip(self, ctx, bc, data, env))]
    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
        bc.debug_check("ClipBox");

        let max_child_width = if self.constrain_horizontal {
            bc.max().width
        } else {
            f64::INFINITY
        };
        let max_child_height = if self.constrain_vertical {
            bc.max().height
        } else {
            f64::INFINITY
        };
        let min_child_size = if self.must_fill { bc.min() } else { Size::ZERO };
        let child_bc =
            BoxConstraints::new(min_child_size, Size::new(max_child_width, max_child_height));

        let bc_changed = child_bc != self.old_bc;
        self.old_bc = child_bc;

        let content_size = if bc_changed || self.child.layout_requested() {
            self.child.layout(ctx, &child_bc, data, env)
        } else {
            self.child.layout_rect().size()
        };

        self.port.content_size = content_size;
        self.port.view_size = bc.constrain(content_size);
        self.port.sanitize_view_origin();

        self.child
            .set_origin(ctx, (Point::ZERO - self.port.view_origin).to_point());

        if self.viewport_size() != self.old_size {
            ctx.view_context_changed();
            self.old_size = self.viewport_size();
        }

        trace!("Computed sized: {}", self.viewport_size());
        self.viewport_size()
    }

    #[instrument(name = "ClipBox", level = "trace", skip(self, ctx, data, env))]
    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
        let clip_rect = ctx.size().to_rect();
        ctx.clip(clip_rect);
        self.child.paint(ctx, data, env);
    }

    fn debug_state(&self, data: &T) -> DebugState {
        DebugState {
            display_name: self.short_type_name().to_string(),
            children: vec![self.child.widget().debug_state(data)],
            ..Default::default()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use test_log::test;

    #[test]
    fn pan_to_visible() {
        let mut viewport = Viewport {
            content_size: Size::new(400., 400.),
            view_size: (20., 20.).into(),
            view_origin: (20., 20.).into(),
        };

        assert!(!viewport.pan_to_visible(Rect::from_origin_size((22., 22.,), (5., 5.))));
        assert!(viewport.pan_to_visible(Rect::from_origin_size((10., 10.,), (5., 5.))));
        assert_eq!(viewport.view_origin, Point::new(10., 10.));
        assert_eq!(viewport.view_size, Size::new(20., 20.));
        assert!(!viewport.pan_to_visible(Rect::from_origin_size((10., 10.,), (50., 50.))));
        assert_eq!(viewport.view_origin, Point::new(10., 10.));

        assert!(viewport.pan_to_visible(Rect::from_origin_size((30., 10.,), (5., 5.))));
        assert_eq!(viewport.view_origin, Point::new(15., 10.));
        assert!(viewport.pan_to_visible(Rect::from_origin_size((5., 5.,), (5., 5.))));
        assert_eq!(viewport.view_origin, Point::new(5., 5.));
    }
}