Browse Source

IPopupPositioner + managed implementation

Nikita Tsukanov 6 năm trước cách đây
mục cha
commit
880a2269fd

+ 294 - 0
src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs

@@ -0,0 +1,294 @@
+// The documentation and flag names in this file are initially taken from
+// xdg_shell wayland protocol this API is designed after
+// therefore, I'm including the license from wayland-protocols repo
+
+/* 
+Copyright © 2008-2013 Kristian Høgsberg
+Copyright © 2010-2013 Intel Corporation
+Copyright © 2013      Rafael Antognolli
+Copyright © 2013      Jasper St. Pierre
+Copyright © 2014      Jonas Ådahl
+Copyright © 2014      Jason Ekstrand
+Copyright © 2014-2015 Collabora, Ltd.
+Copyright © 2015      Red Hat Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice (including the next
+paragraph) shall be included in all copies or substantial portions of the
+Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+---
+
+The above is the version of the MIT "Expat" License used by X.org:
+
+    http://cgit.freedesktop.org/xorg/xserver/tree/COPYING
+    
+    
+Adjustments for Avalonia needs:
+Copyright © 2019 Nikita Tsukanov
+    
+    
+*/
+
+using System;
+
+namespace Avalonia.Controls.Primitives.PopupPositioning
+{
+    /// <summary>
+    /// 
+    /// The IPopupPositioner provides a collection of rules for the placement of a
+    /// a popup relative to its parent. Rules can be defined to ensure
+    /// the popup remains within the visible area's borders, and to
+    /// specify how the popup changes its position, such as sliding along
+    /// an axis, or flipping around a rectangle. These positioner-created rules are
+    /// constrained by the requirement that a popup must intersect with or
+    /// be at least partially adjacent to its parent surface.
+    /// </summary>
+    public struct PopupPositionerParameters
+    {
+        private PopupPositioningEdge _gravity;
+        private PopupPositioningEdge _anchor;
+
+        /// <summary>
+        /// Set the size of the popup that is to be positioned with the positioner
+        /// object. The size is in scaled coordinates.
+        /// </summary>
+        public Size Size { get; set; }
+
+        /// <summary>
+        /// Specify the anchor rectangle within the parent that the popup
+        /// will be placed relative to. The rectangle is relative to the
+        /// parent geometry
+        /// 
+        /// The anchor rectangle may not extend outside the window geometry of the
+        /// popup's parent. The anchor rectangle is in scaled coordinates
+        /// </summary>
+        public Rect AnchorRectangle { get; set; }
+
+
+        /// <summary>
+        /// Defines the anchor point for the anchor rectangle. The specified anchor
+        /// is used derive an anchor point that the popup will be
+        /// positioned relative to. If a corner anchor is set (e.g. 'TopLeft' or
+        /// 'BottomRight'), the anchor point will be at the specified corner;
+        /// otherwise, the derived anchor point will be centered on the specified
+        /// edge, or in the center of the anchor rectangle if no edge is specified.
+        /// </summary>
+        public PopupPositioningEdge Anchor
+        {
+            get => _anchor;
+            set
+            {
+                PopupPositioningEdgeHelper.ValidateEdge(value);
+                _anchor = value;
+            }
+        }
+
+        /// <summary>
+        /// Defines in what direction a popup should be positioned, relative to
+        /// the anchor point of the parent. If a corner gravity is
+        /// specified (e.g. 'BottomRight' or 'TopLeft'), then the popup
+        /// will be placed towards the specified gravity; otherwise, the popup
+        /// will be centered over the anchor point on any axis that had no
+        /// gravity specified.
+        /// </summary>
+        public PopupPositioningEdge Gravity
+        {
+            get => _gravity;
+            set
+            {
+                PopupPositioningEdgeHelper.ValidateEdge(value);
+                _gravity = value;
+            }
+        }
+
+        /// <summary>
+        /// Specify how the popup should be positioned if the originally intended
+        /// position caused the popup to be constrained, meaning at least
+        /// partially outside positioning boundaries set by the positioner. The
+        /// adjustment is set by constructing a bitmask describing the adjustment to
+        /// be made when the popup is constrained on that axis.
+        /// 
+        /// If no bit for one axis is set, the positioner will assume that the child
+        /// surface should not change its position on that axis when constrained.
+        /// 
+        /// If more than one bit for one axis is set, the order of how adjustments
+        /// are applied is specified in the corresponding adjustment descriptions.
+        /// 
+        /// The default adjustment is none.
+        /// </summary>
+        public PopupPositionerConstraintAdjustment ConstraintAdjustment { get; set; }
+        
+        /// <summary>
+        /// Specify the popup position offset relative to the position of the
+        /// anchor on the anchor rectangle and the anchor on the popup. For
+        /// example if the anchor of the anchor rectangle is at (x, y), the popup
+        /// has the gravity bottom|right, and the offset is (ox, oy), the calculated
+        /// surface position will be (x + ox, y + oy). The offset position of the
+        /// surface is the one used for constraint testing. See
+        /// set_constraint_adjustment.
+        /// 
+        /// An example use case is placing a popup menu on top of a user interface
+        /// element, while aligning the user interface element of the parent surface
+        /// with some user interface element placed somewhere in the popup.
+        /// </summary>
+        public Point Offset { get; set; }
+    }
+    
+    /// <summary>
+    /// The constraint adjustment value define ways how popup position will
+    /// be adjusted if the unadjusted position would result in the popup
+    /// being partly constrained.
+    /// 
+    /// Whether a popup is considered 'constrained' is left to the positioner
+    /// to determine. For example, the popup may be partly outside the
+    /// target platform defined 'work area', thus necessitating the popup's
+    /// position be adjusted until it is entirely inside the work area.
+    /// </summary>
+    [Flags]
+    public enum PopupPositionerConstraintAdjustment
+    {
+        /// <summary>
+        /// Don't alter the surface position even if it is constrained on some
+        /// axis, for example partially outside the edge of an output.
+        /// </summary>
+        None = 0,
+
+        /// <summary>
+        /// Slide the surface along the x axis until it is no longer constrained.
+        ///        First try to slide towards the direction of the gravity on the x axis
+        ///        until either the edge in the opposite direction of the gravity is
+        ///        unconstrained or the edge in the direction of the gravity is
+        ///        constrained.
+        ///
+        ///        Then try to slide towards the opposite direction of the gravity on the
+        ///        x axis until either the edge in the direction of the gravity is
+        ///        unconstrained or the edge in the opposite direction of the gravity is
+        ///        constrained.
+        /// </summary>
+        SlideX = 1,
+
+
+        /// <summary>
+        ///            Slide the surface along the y axis until it is no longer constrained.
+        /// 
+        /// First try to slide towards the direction of the gravity on the y axis
+        /// until either the edge in the opposite direction of the gravity is
+        /// unconstrained or the edge in the direction of the gravity is
+        /// constrained.
+        /// 
+        /// Then try to slide towards the opposite direction of the gravity on the
+        /// y axis until either the edge in the direction of the gravity is
+        /// unconstrained or the edge in the opposite direction of the gravity is
+        /// constrained.
+        /// */
+        /// </summary>
+        SlideY = 2,
+
+        /// <summary>
+        /// Invert the anchor and gravity on the x axis if the surface is
+        /// constrained on the x axis. For example, if the left edge of the
+        /// surface is constrained, the gravity is 'left' and the anchor is
+        /// 'left', change the gravity to 'right' and the anchor to 'right'.
+        /// 
+        /// If the adjusted position also ends up being constrained, the resulting
+        /// position of the flip_x adjustment will be the one before the
+        /// adjustment.
+        /// </summary>
+        FlipX = 4,
+
+        /// <summary>
+        /// Invert the anchor and gravity on the y axis if the surface is
+        /// constrained on the y axis. For example, if the bottom edge of the
+        /// surface is constrained, the gravity is 'bottom' and the anchor is
+        /// 'bottom', change the gravity to 'top' and the anchor to 'top'.
+        /// 
+        /// The adjusted position is calculated given the original anchor
+        /// rectangle and offset, but with the new flipped anchor and gravity
+        /// values.
+        /// 
+        /// If the adjusted position also ends up being constrained, the resulting
+        /// position of the flip_y adjustment will be the one before the
+        /// adjustment.
+        /// </summary>
+        FlipY = 8,
+        All = SlideX|SlideY|FlipX|FlipY
+    }
+
+    static class PopupPositioningEdgeHelper
+    {
+        public static void ValidateEdge(this PopupPositioningEdge edge)
+        {
+            if (((edge & PopupPositioningEdge.Left) != 0 && (edge & PopupPositioningEdge.Right) != 0)
+                ||
+                ((edge & PopupPositioningEdge.Top) != 0 && (edge & PopupPositioningEdge.Bottom) != 0))
+                throw new ArgumentException("Opposite edges specified");
+        }
+
+        public static PopupPositioningEdge Flip(this PopupPositioningEdge edge)
+        {
+            var hmask = PopupPositioningEdge.Left | PopupPositioningEdge.Right;
+            var vmask = PopupPositioningEdge.Top | PopupPositioningEdge.Bottom;
+            if ((edge & hmask) != 0)
+                edge ^= hmask;
+            if ((edge & vmask) != 0)
+                edge ^= vmask;
+            return edge;
+        }
+
+        public static PopupPositioningEdge FlipX(this PopupPositioningEdge edge)
+        {
+            if ((edge & PopupPositioningEdge.HorizontalMask) != 0)
+                edge ^= PopupPositioningEdge.HorizontalMask;
+            return edge;
+        }
+        
+        public static PopupPositioningEdge FlipY(this PopupPositioningEdge edge)
+        {
+            if ((edge & PopupPositioningEdge.VerticalMask) != 0)
+                edge ^= PopupPositioningEdge.VerticalMask;
+            return edge;
+        }
+        
+    }
+
+    [Flags]
+    public enum PopupPositioningEdge
+    {
+        None,
+        Top = 1,
+        Bottom = 2,
+        Left = 4,
+        Right = 8,
+        TopLeft = Top | Left,
+        TopRight = Top | Right,
+        BottomLeft = Bottom | Left,
+        BottomRight = Bottom | Right,
+
+        
+        VerticalMask = Top | Bottom,
+        HorizontalMask = Left | Right,
+        AllMask = VerticalMask|HorizontalMask
+    }
+
+    public interface IPopupPositioner
+    {
+        void Update(PopupPositionerParameters parameters);
+    }
+
+
+}

+ 174 - 0
src/Avalonia.Controls/Primitives/PopupPositioning/ManagedPopupPositioner.cs

@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Avalonia.Controls.Primitives.PopupPositioning
+{
+    public interface IManagedPopupPositionerPopup
+    {
+        IReadOnlyList<ManagedPopupPositionerScreenInfo> Screens { get; }
+        Rect ParentClientAreaScreenGeometry { get; }
+        void MoveAndResize(Point devicePoint, Size virtualSize);
+        Point TranslatePoint(Point pt);
+        Size TranslateSize(Size size);
+    }
+
+    public class ManagedPopupPositionerScreenInfo
+    {
+        public Rect Bounds { get; }
+        public Rect WorkingArea { get; }
+
+        public ManagedPopupPositionerScreenInfo(Rect bounds, Rect workingArea)
+        {
+            Bounds = bounds;
+            WorkingArea = workingArea;
+        }
+    }
+
+    public class ManagedPopupPositioner : IPopupPositioner
+    {
+        private readonly IManagedPopupPositionerPopup _popup;
+
+        public ManagedPopupPositioner(IManagedPopupPositionerPopup popup)
+        {
+            _popup = popup;
+        }
+
+
+        static Point GetAnchorPoint(Rect anchorRect, PopupPositioningEdge edge)
+        {
+            double x, y;
+            if ((edge & PopupPositioningEdge.Left) != 0)
+                x = anchorRect.X;
+            else if ((edge & PopupPositioningEdge.Right) != 0)
+                x = anchorRect.Right;
+            else
+                x = anchorRect.X + anchorRect.Width / 2;
+            
+            if ((edge & PopupPositioningEdge.Top) != 0)
+                y = anchorRect.Y;
+            else if ((edge & PopupPositioningEdge.Bottom) != 0)
+                y = anchorRect.Bottom;
+            else
+                y = anchorRect.Y + anchorRect.Height / 2;
+            return new Point(x, y);
+        }
+
+        static Point Gravitate(Point anchorPoint, Size size, PopupPositioningEdge gravity)
+        {
+            double x, y;
+            if ((gravity & PopupPositioningEdge.Left) != 0)
+                x = -size.Width;
+            else if ((gravity & PopupPositioningEdge.Right) != 0)
+                x = 0;
+            else
+                x = -size.Width / 2;
+            
+            if ((gravity & PopupPositioningEdge.Top) != 0)
+                y = -size.Height;
+            else if ((gravity & PopupPositioningEdge.Bottom) != 0)
+                y = 0;
+            else
+                y = -size.Height / 2;
+            return anchorPoint + new Point(x, y);
+        }
+
+        public void Update(PopupPositionerParameters parameters)
+        {
+
+            Update(_popup.TranslateSize(parameters.Size),
+                new Rect(_popup.TranslatePoint(parameters.AnchorRectangle.TopLeft),
+                    _popup.TranslateSize(parameters.AnchorRectangle.Size)),
+                parameters.Anchor, parameters.Gravity, parameters.ConstraintAdjustment,
+                _popup.TranslatePoint(parameters.Offset));
+        }
+
+        
+        void Update(Size size, Rect anchorRect, PopupPositioningEdge anchor, PopupPositioningEdge gravity,
+            PopupPositionerConstraintAdjustment constraintAdjustment, Point offset)
+        {
+            var parentGeometry = _popup.ParentClientAreaScreenGeometry;
+            anchorRect = anchorRect.Translate(parentGeometry.TopLeft);
+            
+            Rect GetBounds()
+            {
+                var screens = _popup.Screens;
+                
+                var targetScreen = screens.FirstOrDefault(s => s.Bounds.Contains(anchorRect.TopLeft))
+                                   ?? screens.FirstOrDefault(s => s.Bounds.Intersects(anchorRect))
+                                   ?? screens.FirstOrDefault(s => s.Bounds.Contains(parentGeometry.TopLeft))
+                                   ?? screens.FirstOrDefault(s => s.Bounds.Intersects(parentGeometry))
+                                   ?? screens.FirstOrDefault();
+                return targetScreen?.WorkingArea
+                       ?? new Rect(0, 0, double.MaxValue, double.MaxValue);
+            }
+
+            var bounds = GetBounds();
+
+            bool FitsInBounds(Rect rc, PopupPositioningEdge edge = PopupPositioningEdge.AllMask)
+            {
+                if ((edge & PopupPositioningEdge.Left) != 0
+                    && rc.X < bounds.X)
+                    return false;
+
+                if ((edge & PopupPositioningEdge.Top) != 0
+                    && rc.Y < bounds.Y)
+                    return false;
+
+                if ((edge & PopupPositioningEdge.Right) != 0
+                    && rc.Right > bounds.Right)
+                    return false;
+
+                if ((edge & PopupPositioningEdge.Bottom) != 0
+                    && rc.Bottom > bounds.Bottom)
+                    return false;
+
+                return true;
+            }
+
+            Rect GetUnconstrained(PopupPositioningEdge a, PopupPositioningEdge g) =>
+                new Rect(Gravitate(GetAnchorPoint(anchorRect, a), size, g) + offset, size);
+
+
+            var geo = GetUnconstrained(anchor, gravity);
+
+            // If flipping geometry and anchor is allowed and helps, use the flipped one,
+            // otherwise leave it as is
+            if (!FitsInBounds(geo, PopupPositioningEdge.HorizontalMask)
+                && (constraintAdjustment & PopupPositionerConstraintAdjustment.FlipX) != 0)
+            {
+                var flipped = GetUnconstrained(anchor.FlipX(), gravity.FlipX());
+                if (FitsInBounds(flipped, PopupPositioningEdge.HorizontalMask))
+                    geo = geo.WithX(flipped.X);
+            }
+
+            // If sliding is allowed, try moving the rect into the bounds
+            if ((constraintAdjustment & PopupPositionerConstraintAdjustment.SlideX) != 0)
+            {
+                geo = geo.WithX(Math.Max(geo.X, bounds.X));
+                if (geo.Right > bounds.Right)
+                    geo = geo.WithX(bounds.Right - geo.Width);
+            }
+            
+            // If flipping geometry and anchor is allowed and helps, use the flipped one,
+            // otherwise leave it as is
+            if (!FitsInBounds(geo, PopupPositioningEdge.VerticalMask)
+                && (constraintAdjustment & PopupPositionerConstraintAdjustment.FlipY) != 0)
+            {
+                var flipped = GetUnconstrained(anchor.FlipY(), gravity.FlipY());
+                if (FitsInBounds(flipped, PopupPositioningEdge.VerticalMask))
+                    geo = geo.WithY(flipped.Y);
+            }
+
+            // If sliding is allowed, try moving the rect into the bounds
+            if ((constraintAdjustment & PopupPositionerConstraintAdjustment.SlideY) != 0)
+            {
+                geo = geo.WithY(Math.Max(geo.Y, bounds.Y));
+                if (geo.Bottom > bounds.Bottom)
+                    geo = geo.WithY(bounds.Bottom - geo.Height);
+            }
+
+            _popup.MoveAndResize(geo.TopLeft, size);
+        }
+    }
+}

+ 50 - 0
src/Avalonia.Controls/Primitives/PopupPositioning/ManagedPopupPositionerPopupImplHelper.cs

@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Platform;
+
+namespace Avalonia.Controls.Primitives.PopupPositioning
+{
+    /// <summary>
+    /// This class is used to simplify integration of IPopupImpl implementations with popup positioner
+    /// </summary>
+    public class ManagedPopupPositionerPopupImplHelper : IManagedPopupPositionerPopup 
+    {
+        private readonly IWindowBaseImpl _parent;
+
+        public delegate void MoveResizeDelegate(PixelPoint position, Size size, double scaling);
+        private readonly MoveResizeDelegate _moveResize;
+
+        public ManagedPopupPositionerPopupImplHelper(IWindowBaseImpl parent, MoveResizeDelegate moveResize)
+        {
+            _parent = parent;
+            _moveResize = moveResize;
+        }
+
+        public IReadOnlyList<ManagedPopupPositionerScreenInfo> Screens =>
+
+            _parent.Screen.AllScreens.Select(s => new ManagedPopupPositionerScreenInfo(
+                s.Bounds.ToRect(_parent.Scaling), s.WorkingArea.ToRect(_parent.Scaling))).ToList();
+
+        public Rect ParentClientAreaScreenGeometry
+        {
+            get
+            {
+                // Popup positioner operates with abstract coordinates, but in our case they are pixel ones
+                var point = _parent.PointToScreen(default);
+                var size = PixelSize.FromSize(_parent.ClientSize, _parent.Scaling);
+                return new Rect(point.X, point.Y, size.Width, size.Height);
+
+            }
+        }
+
+        public void MoveAndResize(Point devicePoint, Size virtualSize)
+        {
+            _moveResize(new PixelPoint((int)devicePoint.X, (int)devicePoint.Y), virtualSize, _parent.Scaling);
+        }
+
+        public Point TranslatePoint(Point pt) => pt * _parent.Scaling;
+
+        public Size TranslateSize(Size size) => size * _parent.Scaling;
+    }
+}