-
-
Notifications
You must be signed in to change notification settings - Fork 4k
Description
What problem does this solve or what need does it fill?
The Bevy editor, and other similar utilities, are going to need various kinds of popover widgets:
- Menu buttons and menu bars
- Context menus
- Tooltips
These widgets have a number of unique requirements:
- They are typically positioned relative to an anchor element (much like an absolute-positioned child) or anchor point.
- However, unlike a child, they are not clipped even if the parent is. The popover appears above all other UI content.
- The position of the popup can "flip" if there's not enough room on the screen to display it on that side. So for example, if there isn't enough room to display a drop-down menu at the bottom of the screen, it can switch to a "drop-up".
Consider for example a tree view widget displaying nodes in a scene graph. Given the complexity of scene graph editing, we may want to have icon menu buttons (small buttons displaying an icon with a drop-down chevron) to provide the user with various actions to perform on a given tree node. However, because the tree view is in a scrolling panel, the tree nodes are clipped (Overflow::scroll
). The popover, however, should be allowed to extend outside of the scrolling area. This is true for both dropdown menus and tooltips.
Calculating the popup position is a relatively straightforward calculation, although somewhat involved. Basically you need to know three things: the window rectangle, the anchor rectangle, and the popup's minimum size. Flipping can be done by calculating which side would cause the least amount of clipping of the popup rectangle.
For some kinds of popups, such as select components, it can also be desirable to stretch the popup's width so that it matches the width of the anchor entity. Also, for popups that are very tall and which allow scrolling, you may want to constrain the popup's size so that it is never taller than the window.
If we didn't have to worry about clipping, then we could make the popup a child of the anchor. However, since there's no way in Bevy for a child entity to "opt-out" of it's parent's clipping configuration, we can't make the popup a child. Instead, the popup has to be a root node, or at the very least, it has to be located higher in the UI node hierarchy.
Having the popup be a root node introduces a number of issues. For one thing, we may want the child to trigger events that the anchor element can receive. This can be solved easily and is out of scope for this issue.
The other issue is maintaining the relative position between the popup and the anchor. This needs to be done explicitly by updating the popup's absolute coordinates. This can be done via an ECS system that runs in PreUpdate
.
However, there's a problem with updating the coordinates, which is that once we calculate the popup's position (which can't be done until we know where the anchor is located, which requires that the layout pass be completed), we need to run a second layout pass for the popup. This layout pass only needs to be run if the absolute coordinates of the popup actually changed - more precisely, it needs to be done if any popup's coordinates changed.
If we don't do a second layout pass, then there will be a one-frame delay: the popup will always lag behind the position of the anchor. In cases where we set winit's UpdateMode
to desktop
the delay is much longer, potentially several seconds.
Note that if we decide to support submenus, the problem gets worse: calculating the position of the submenu requires knowing the position of the parent menu (requiring a layout) which also requires knowing the position of the anchor (also requiring a layout).
I've been told that doing additional layout passes is actually a fairly cheap operation since most of the layout information is cached. It would be even cheaper if we could limit it to just the popovers whose coordinates had changed - possibly by adding a "needs layout" marker component.
Note that in the world of browsers, layout works somewhat differently. Modifying the DOM causes the layout to be marked as out of date, but does not immediately trigger a layout pass. A layout pass will be triggered the next time you try to read the position or size property of an element. This means that in browsers, you typically want to batch all your reads separately from your writes - the worst thing you (performance-wise) is alternate between mutations and reads.
The result is that in browsers the number of layout passes is arbitrary and not tied to frame rate.
What solution would you like?
I don't have a specific solution to recommend yet, I wanted to gather ideas from others first.