diff --git a/druid/examples/viewport_header.rs b/druid/examples/viewport_header.rs new file mode 100755 index 000000000..e73849b2c --- /dev/null +++ b/druid/examples/viewport_header.rs @@ -0,0 +1,160 @@ +// Copyright 2019 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. + +//! Shows a scroll widget, and also demonstrates how widgets that paint +//! outside their bounds can specify their paint region. + +// On Windows platform, don't show a console when opening the app. +#![windows_subsystem = "windows"] + +use druid::im::Vector; +use druid::widget::prelude::*; +use druid::widget::{Button, Controller, Flex, Label, List, Side, TextBox, ViewportHeader}; +use druid::{ + AppLauncher, Color, Data, Insets, Lens, LocalizedString, RoundedRectRadii, Selector, WidgetExt, + WindowDesc, +}; +use std::sync::Arc; + +#[derive(Clone, Data, Lens)] +struct AppData { + list: Vector, + count: usize, +} + +#[derive(Clone, Data, Lens)] +struct Contact { + name: Arc, + info: Vector>, + id: usize, +} + +pub fn main() { + let window = WindowDesc::new(build_widget()) + .title(LocalizedString::new("scroll-demo-window-title").with_placeholder("Scroll demo")); + + let mut list = Vector::new(); + list.push_back(Arc::new("test".to_string())); + list.push_back(Arc::new("test2".to_string())); + list.push_back(Arc::new("test3".to_string())); + + AppLauncher::with_window(window) + .log_to_console() + .launch(AppData { + list: Vector::new(), + count: 0, + }) + .expect("launch failed"); +} + +fn build_widget() -> impl Widget { + let list = List::new(|| { + let body = Flex::column() + .with_default_spacer() + .with_child(Label::new("Name:").align_left()) + .with_default_spacer() + .with_child(TextBox::new().lens(Contact::name).expand_width()) + .with_default_spacer() + .with_default_spacer() + .with_child(Label::new("Info:").align_left()) + .with_default_spacer() + .with_child( + List::new(|| { + TextBox::new() + .padding(Insets::new(15.0, 0.0, 0.0, 10.0)) + .expand_width() + }) + .lens(Contact::info), + ) + .with_child( + Button::new("Add Info").on_click(|_, data: &mut Contact, _| { + data.info.push_back(Arc::new(String::new())) + }), + ) + .with_default_spacer() + .align_left() + .padding(Insets::uniform_xy(25.0, 0.0)) + .background(Color::grey8(25)) + .rounded(RoundedRectRadii::new(0.0, 0.0, 10.0, 10.0)); + + let header = Flex::row() + .with_flex_child( + Label::dynamic(|data: &Contact, _| format!("Contact \"{}\"", &data.name)).center(), + 1.0, + ) + .with_child( + Button::new("X") + .on_click(|ctx, data: &mut Contact, _| { + ctx.submit_notification(REMOVE_ID.with(data.id)) + }) + .padding(5.0), + ) + .center() + .background(Color::grey8(15)) + .rounded(RoundedRectRadii::new(10.0, 10.0, 0.0, 0.0)); + + ViewportHeader::new(body, header, Side::Top) + .clipped_content(true) + .with_minimum_visible_content(20.0) + .padding(Insets::uniform_xy(0.0, 5.0)) + }) + .lens(AppData::list) + .controller(RemoveID) + .scroll() + .vertical(); + + Flex::column() + .with_flex_child(list, 1.0) + .with_default_spacer() + .with_child( + Button::new("Add Contact").on_click(|_, data: &mut AppData, _| { + let name = if data.count == 0 { + "New Contact".to_string() + } else { + format!("New Contact #{}", data.count) + }; + let id = data.count; + data.count += 1; + data.list.push_back(Contact { + name: Arc::new(name), + info: Default::default(), + id, + }) + }), + ) +} + +const REMOVE_ID: Selector = Selector::new("org.druid.example.remove_id"); + +struct RemoveID; + +impl> Controller for RemoveID { + fn event( + &mut self, + child: &mut W, + ctx: &mut EventCtx, + event: &Event, + data: &mut AppData, + env: &Env, + ) { + if let Event::Notification(notification) = event { + if let Some(id) = notification.get(REMOVE_ID) { + ctx.set_handled(); + data.list.retain(|c| c.id != *id); + } + } else { + child.event(ctx, event, data, env); + } + } +} diff --git a/druid/examples/z_stack_bug.rs b/druid/examples/z_stack_bug.rs new file mode 100644 index 000000000..29d4dc099 --- /dev/null +++ b/druid/examples/z_stack_bug.rs @@ -0,0 +1,41 @@ +use druid::widget::{CrossAxisAlignment, Flex, Label, Scroll, SizedBox, ZStack}; +use druid::{AppLauncher, Color, UnitPoint, Widget, WidgetExt, WindowDesc}; + +fn main() { + let window = WindowDesc::new(build_ui()); + + AppLauncher::with_window(window) + .log_to_console() + .launch(()) + .unwrap(); +} + +fn build_ui() -> impl Widget<()> { + let mut container = Flex::column().cross_axis_alignment(CrossAxisAlignment::Fill); + + for _ in 0..10 { + let stack = ZStack::new( + Label::new("Base layer") + .align_vertical(UnitPoint::TOP) + .expand_width() + .fix_height(200.0) + .background(Color::grey8(20)), + ) + .with_centered_child( + Label::new("Overlay") + .center() + .fix_height(100.0) + .background(Color::grey8(0)), + ); + + container.add_child(SizedBox::empty().height(200.0)); + container.add_child( + Flex::row() + .with_flex_child(stack, 1.0) + .with_default_spacer() + .with_child(SizedBox::empty()), + ); + } + + Scroll::new(container).vertical() +} diff --git a/druid/src/widget/align.rs b/druid/src/widget/align.rs index 277f52d40..a6f6c2f85 100644 --- a/druid/src/widget/align.rs +++ b/druid/src/widget/align.rs @@ -14,6 +14,7 @@ //! A widget that aligns its child (for example, centering it). +use crate::contexts::ChangeCtx; use crate::debug_state::DebugState; use crate::widget::prelude::*; use crate::{Data, Rect, Size, UnitPoint, WidgetPod}; @@ -25,6 +26,8 @@ pub struct Align { child: WidgetPod>>, width_factor: Option, height_factor: Option, + in_viewport: bool, + viewport: Rect, } impl Align { @@ -39,6 +42,8 @@ impl Align { child: WidgetPod::new(child).boxed(), width_factor: None, height_factor: None, + in_viewport: false, + viewport: Rect::new(0.0, 0.0, f64::INFINITY, f64::INFINITY), } } @@ -64,6 +69,8 @@ impl Align { child: WidgetPod::new(child).boxed(), width_factor: None, height_factor: Some(1.0), + in_viewport: false, + viewport: Rect::new(0.0, 0.0, f64::INFINITY, f64::INFINITY), } } @@ -74,8 +81,47 @@ impl Align { child: WidgetPod::new(child).boxed(), width_factor: Some(1.0), height_factor: None, + in_viewport: false, + viewport: Rect::new(0.0, 0.0, f64::INFINITY, f64::INFINITY), } } + + /// The `Align` widget should only consider the visible space for alignment. + /// + /// When the `Align` widget is fully visible, this option has no effect. When the align widget + /// gets scrolled out of view, the wrapped widget will move to stay inside the visible area. + /// The wrapped widget will always stay inside the bounds of the `Align` widget. + pub fn in_viewport(mut self) -> Self { + self.in_viewport = true; + self + } + + fn align(&mut self, ctx: &mut impl ChangeCtx, my_size: Size) { + let size = self.child.layout_rect().size(); + + let extra_width = (my_size.width - size.width).max(0.); + let extra_height = (my_size.height - size.height).max(0.); + + // The part of our layout_rect the origin of the child is allowed to be in + let mut extra_space = Rect::new(0., 0., extra_width, extra_height); + + if self.in_viewport { + // The part of the viewport the origin of the child is allowed to be in + let viewport = + Rect::from_origin_size(self.viewport.origin(), self.viewport.size() - size); + + // Essentially Rect::intersect but if the two rectangles dont intersect this + // implementation chooses the point closed to viewpor inside extra_space to always give + // the child a valid origin. + extra_space.x0 = extra_space.x0.clamp(viewport.x0, extra_space.x1); + extra_space.y0 = extra_space.y0.clamp(viewport.y0, extra_space.y1); + extra_space.x1 = extra_space.x1.clamp(extra_space.x0, viewport.x1); + extra_space.y1 = extra_space.y1.clamp(extra_space.y0, viewport.y1); + } + + let origin = self.align.resolve(extra_space).expand(); + self.child.set_origin(ctx, origin); + } } impl Widget for Align { @@ -86,6 +132,14 @@ impl Widget for Align { #[instrument(name = "Align", level = "trace", skip(self, ctx, event, data, env))] fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { + // THis needs to happen before passing the event to the child. + if let LifeCycle::ViewContextChanged(view_ctx) = event { + self.viewport = view_ctx.clip; + if self.in_viewport { + self.align(ctx, ctx.size()); + } + } + self.child.lifecycle(ctx, event, data, env) } @@ -118,27 +172,24 @@ impl Widget for Align { my_size.height = size.height * height; } - my_size = bc.constrain(my_size); - let extra_width = (my_size.width - size.width).max(0.); - let extra_height = (my_size.height - size.height).max(0.); - let origin = self - .align - .resolve(Rect::new(0., 0., extra_width, extra_height)) - .expand(); - self.child.set_origin(ctx, origin); + let my_size = bc.constrain(my_size); + self.align(ctx, my_size); let my_insets = self.child.compute_parent_paint_insets(my_size); ctx.set_paint_insets(my_insets); + if self.height_factor.is_some() { let baseline_offset = self.child.baseline_offset(); if baseline_offset > 0f64 { - ctx.set_baseline_offset(baseline_offset + extra_height / 2.0); + ctx.set_baseline_offset( + my_size.height - self.child.layout_rect().y1 + baseline_offset, + ); } } trace!( "Computed layout: origin={}, size={}, insets={:?}", - origin, + self.child.layout_rect().origin(), my_size, my_insets ); diff --git a/druid/src/widget/flex.rs b/druid/src/widget/flex.rs index 9fd931282..e2287e628 100644 --- a/druid/src/widget/flex.rs +++ b/druid/src/widget/flex.rs @@ -19,7 +19,7 @@ use std::ops::Add; use crate::debug_state::DebugState; use crate::kurbo::{common::FloatExt, Vec2}; use crate::widget::prelude::*; -use crate::{Data, KeyOrValue, Point, Rect, WidgetPod}; +use crate::{Data, Insets, KeyOrValue, Point, Rect, WidgetPod}; use tracing::{instrument, trace}; /// A container with either horizontal or vertical layout. @@ -197,6 +197,108 @@ pub enum Axis { Vertical, } +/// One of two sides of an Axis. +/// +/// This value is useful combination with an axis to indicate a side of a Rectangle. +#[derive(Data, Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orientation { + /// Start + Start, + /// End + End, +} + +impl Orientation { + /// brings two elements in visual order. + pub fn order(&self, reference: T, side: T) -> (T, T) { + match self { + Orientation::Start => (side, reference), + Orientation::End => (reference, side), + } + } +} + +/// Represents one of the sides of a Rectangle. +#[derive(Data, Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + /// The top side of a rectangle (y0). + Top, + /// The left side of a rectangle (x0). + Left, + /// The right side of a rectangle (x1). + Right, + /// The bottom side of a rectangle (y1). + Bottom, +} + +impl Side { + /// Creates a new Side from an Axis and an Orientation + pub fn pack(axis: Axis, orientation: Orientation) -> Self { + match (axis, orientation) { + (Axis::Horizontal, Orientation::Start) => Side::Left, + (Axis::Horizontal, Orientation::End) => Side::Right, + (Axis::Vertical, Orientation::Start) => Side::Top, + (Axis::Vertical, Orientation::End) => Side::Bottom, + } + } + + /// The Axis of this Side + pub fn axis(&self) -> Axis { + match self { + Side::Top | Side::Bottom => Axis::Vertical, + Side::Left | Side::Right => Axis::Horizontal, + } + } + + /// Orientation of this Side + pub fn orientation(&self) -> Orientation { + match self { + Side::Top | Side::Left => Orientation::Start, + Side::Bottom | Side::Right => Orientation::End, + } + } + + /// returns a Vec2 which points in the direction of this Side. + pub fn direction(&self) -> Vec2 { + match self { + Side::Top => Vec2::new(0.0, -1.0), + Side::Bottom => Vec2::new(0.0, 1.0), + Side::Left => Vec2::new(-1.0, 0.0), + Side::Right => Vec2::new(1.0, 0.0), + } + } + + /// returns the value of an `Inset` at this side. + pub fn from_inset(&self, insets: Insets) -> f64 { + match self { + Side::Top => insets.y0, + Side::Left => insets.x0, + Side::Right => insets.x1, + Side::Bottom => insets.y1, + } + } + + /// creates an inset which has amount on this side and zero everywhere else. + pub fn as_insets(&self, amount: f64) -> Insets { + let mut insets = Insets::ZERO; + match self { + Side::Top => { + insets.y0 = amount; + } + Side::Left => { + insets.x0 = amount; + } + Side::Right => { + insets.x1 = amount; + } + Side::Bottom => { + insets.y1 = amount; + } + } + insets + } +} + impl Axis { /// Get the axis perpendicular to this one. pub fn cross(self) -> Axis { diff --git a/druid/src/widget/mod.rs b/druid/src/widget/mod.rs index b791e3f9a..590d49059 100644 --- a/druid/src/widget/mod.rs +++ b/druid/src/widget/mod.rs @@ -60,6 +60,7 @@ mod tabs; mod textbox; mod value_textbox; mod view_switcher; +mod viewport_header; #[allow(clippy::module_inception)] mod widget; mod widget_ext; @@ -79,7 +80,7 @@ pub use controller::{Controller, ControllerHost}; pub use disable_if::DisabledIf; pub use either::Either; pub use env_scope::EnvScope; -pub use flex::{Axis, CrossAxisAlignment, Flex, FlexParams, MainAxisAlignment}; +pub use flex::{Axis, CrossAxisAlignment, Flex, FlexParams, MainAxisAlignment, Orientation, Side}; pub use identity_wrapper::IdentityWrapper; pub use intrinsic_width::IntrinsicWidth; pub use label::{Label, LabelText, LineBreaking, RawLabel}; @@ -105,6 +106,7 @@ pub use tabs::{TabInfo, Tabs, TabsEdge, TabsPolicy, TabsState, TabsTransition}; pub use textbox::TextBox; pub use value_textbox::{TextBoxEvent, ValidationDelegate, ValueTextBox}; pub use view_switcher::ViewSwitcher; +pub use viewport_header::{ViewportHeader, ViewportHeaderConfig}; #[doc(hidden)] pub use widget::{Widget, WidgetId}; #[doc(hidden)] diff --git a/druid/src/widget/viewport_header.rs b/druid/src/widget/viewport_header.rs new file mode 100644 index 000000000..78b60fa41 --- /dev/null +++ b/druid/src/widget/viewport_header.rs @@ -0,0 +1,277 @@ +use crate::commands::SCROLL_TO_VIEW; +use crate::widget::flex::{Orientation, Side}; +use crate::{ + BoxConstraints, Data, Env, Event, EventCtx, InternalEvent, LayoutCtx, LifeCycle, LifeCycleCtx, + PaintCtx, Point, Rect, Size, UpdateCtx, ViewContext, Widget, WidgetPod, +}; +use druid::RenderContext; + +/// A widget, containing two widgets with horizontal or vertical layout. +/// +/// When the `ViewportHeader` is moved out of the viewport, the `header` widget tries to stay inside +/// the viewport by moving over the `content` if necessary. It will always stay inside the +/// `ViewportHeader`'s bounds. +pub struct ViewportHeader { + header: WidgetPod>>, + content: WidgetPod>>, + + header_config: ViewportHeaderConfig, + clip_content: bool, +} + +/// ViewportHeaderConfig contains the information necessary to create the layout of [`ViewportHeader`] +pub struct ViewportHeaderConfig { + content_size: Size, + viewport: Rect, + header_side: Side, + header_size: f64, + minimum_visible_content: f64, +} + +impl ViewportHeaderConfig { + /// creates a new config. + /// + /// side: the side at which the header is located. + pub fn new(side: Side) -> Self { + Self { + content_size: Size::ZERO, + viewport: Rect::from_origin_size( + Point::ORIGIN, + Size::new(f64::INFINITY, f64::INFINITY), + ), + header_side: side, + header_size: 0.0, + minimum_visible_content: 0.0, + } + } + + /// The minimum visible content constrained by the the actual size of the content on that + /// axis. + pub fn minimum_visible(&self) -> f64 { + self.minimum_visible_content + .min(self.header_side.axis().major(self.content_size)) + } + + /// The the layout size of header and content together, when both are fully in view. + pub fn size(&self) -> Size { + self.content_size + Size::from(self.header_side.axis().pack(self.header_size, 0.0)) + } + + /// The side of the header. + pub fn side(&self) -> Side { + self.header_side + } + + /// The amount of pixels the header has be moved into the direction of content to stay inside + /// of the viewport. + /// + /// The max value of this function is `major_content_size - minimum_visible_content`. + /// Therefore header cant leave the content on the other side. + pub fn overlapping(&self) -> f64 { + //Compute Clipped area + let global_layout_rect = Rect::from_origin_size(Point::ZERO, self.size()); + let insets = global_layout_rect - self.viewport; + + //Compute max movable distance + let axis = self.header_side.axis(); + let max = axis.major(self.content_size) - self.minimum_visible(); + + self.header_side.from_inset(insets).clamp(0.0, max) + } + + /// The amount of pixels the viewport of the content gets cropped by the header. + pub fn viewport_crop(&self) -> f64 { + self.overlapping().min(self.header_size) + } + + /// Returns the origin of the content and of the header. + pub fn origins(&self) -> (Point, Point) { + let orientation = self.header_side.orientation(); + let axis = self.header_side.axis(); + + let (first, _) = orientation.order(axis.major(self.content_size), self.header_size); + + let (content_origin, header_origin) = + orientation.order(Point::ZERO, Point::from(axis.pack(first, 0.0))); + let header_origin = header_origin - self.header_side.direction() * self.overlapping(); + + (content_origin, header_origin) + } + + /// Updates a `scroll_to_view` request of the content to take the additional viewport crop into + /// account. + /// + /// Dont call call this with requests of the header widget. + pub fn transform_content_scroll_to_view(&self, ctx: &mut EventCtx, rect: Rect) { + // The length on the major axis with is overlapped by the header. + let viewport_crop = self.viewport_crop(); + + if viewport_crop != 0.0 { + ctx.set_handled(); + + let new_rect = rect + self.header_side.direction() * viewport_crop; + ctx.submit_notification_without_warning(SCROLL_TO_VIEW.with(new_rect)); + } + } + + /// Updates the ViewContext of the widget. + /// + /// Should be called when the widget receives a `Lifecycle::ViewContextChanged` event. + pub fn update_context(&mut self, view_context: ViewContext) { + self.viewport = view_context.clip; + } + + /// Updates the content size. + /// + /// Should be called in layout. + pub fn set_content_size(&mut self, content_size: Size) { + self.content_size = content_size; + } + + /// Updates the header size. + /// + /// should be called in layout + pub fn set_header_size(&mut self, header_size: Size) { + let axis = self.header_side.axis(); + self.header_size = axis.major(header_size); + } + + /// Sets the minimum visible content. + pub fn set_minimum_visible_content(&mut self, visible: f64) { + self.minimum_visible_content = visible; + } +} + +impl ViewportHeader { + /// Creates a new ViewportHeader widget with a given side for the header. + pub fn new( + content: impl Widget + 'static, + header: impl Widget + 'static, + side: Side, + ) -> Self { + Self { + header: WidgetPod::new(Box::new(header)), + content: WidgetPod::new(Box::new(content)), + header_config: ViewportHeaderConfig::new(side), + clip_content: false, + } + } + + /// The amount of Pixels on the main axis of the header, which is always visible of the content. + /// + pub fn with_minimum_visible_content(mut self, minimum_visible_content: f64) -> Self { + self.header_config + .set_minimum_visible_content(minimum_visible_content); + self + } + + /// Builder-style method to set whether the additional cropped viewport should be clipped from + /// from the content. + pub fn clipped_content(mut self, clipped_content: bool) -> Self { + self.clip_content = clipped_content; + self + } +} + +impl Widget for ViewportHeader { + fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { + if let Event::Notification(notification) = event { + if let Some(rect) = notification.get(SCROLL_TO_VIEW) { + if notification.route() == self.content.id() { + // The content is additionally cropped by the header, therefore we move the scroll + // request by the amount + self.header_config + .transform_content_scroll_to_view(ctx, *rect); + } + return; + } + } + + self.header.event(ctx, event, data, env); + if self.header.is_hot() && event.is_pointer_event() { + if self.content.is_active() { + ctx.set_handled(); + } else { + self.content + .event(ctx, &Event::Internal(InternalEvent::MouseLeave), data, env); + return; + } + } + self.content.event(ctx, event, data, env); + } + + fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { + match event { + LifeCycle::ViewContextChanged(view_context) => { + self.header_config.update_context(*view_context); + let (_, header_origin) = self.header_config.origins(); + + self.header.set_origin(ctx, header_origin); + self.header.lifecycle(ctx, event, data, env); + + let mut content_view_context = *view_context; + if self.header.is_hot() { + content_view_context.last_mouse_position = None; + } + content_view_context.clip = content_view_context.clip + - self + .header_config + .side() + .as_insets(self.header_config.viewport_crop()); + + self.content.lifecycle(ctx, event, data, env); + } + LifeCycle::BuildFocusChain + if self.header_config.side().orientation() == Orientation::End => + { + self.content.lifecycle(ctx, event, data, env); + self.header.lifecycle(ctx, event, data, env); + } + _ => { + self.header.lifecycle(ctx, event, data, env); + self.content.lifecycle(ctx, event, data, env); + } + } + } + + fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { + self.header.update(ctx, data, env); + self.content.update(ctx, data, env); + } + + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { + let axis = self.header_config.side().axis(); + + let content_size = self.content.layout(ctx, bc, data, env); + self.header_config.set_content_size(content_size); + let header_bc = BoxConstraints::new( + Size::from(axis.pack(0.0, axis.minor(content_size))), + Size::from(axis.pack(f64::INFINITY, axis.minor(content_size))), + ); + + let header_size = self.header.layout(ctx, &header_bc, data, env); + self.header_config.set_header_size(header_size); + + let (content_origin, header_origin) = self.header_config.origins(); + + self.header.set_origin(ctx, header_origin); + self.content.set_origin(ctx, content_origin); + + self.header_config.size() + } + + fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { + ctx.with_save(|ctx| { + if self.clip_content { + let content_rect = self.content.layout_rect() + - self + .header_config + .side() + .as_insets(self.header_config.overlapping()); + ctx.clip(content_rect); + } + self.content.paint(ctx, data, env); + }); + self.header.paint(ctx, data, env); + } +}