haxe/ui/containers/menus/Menu.hx
package haxe.ui.containers.menus;
import haxe.ui.behaviours.DataBehaviour;
import haxe.ui.behaviours.DefaultBehaviour;
import haxe.ui.components.Button;
import haxe.ui.components.Label;
import haxe.ui.containers.Box;
import haxe.ui.core.Component;
import haxe.ui.core.CompositeBuilder;
import haxe.ui.core.Screen;
import haxe.ui.events.MenuEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.geom.Size;
import haxe.ui.layouts.VerticalLayout;
import haxe.ui.util.Timer;
import haxe.ui.util.Variant;
#if (haxe_ver >= 4.2)
import Std.isOfType;
#else
import Std.is as isOfType;
#end
@:composite(MenuEvents, Builder, Layout)
class Menu extends Box {
@:behaviour(DefaultBehaviour) public var menuStyleNames:String;
@:behaviour(CurrentIndexBehaviour, -1) public var currentIndex:Int;
@:behaviour(CurrentItemBehaviour, null) public var currentItem:MenuItem;
public var menuBar:MenuBar = null;
/**
Utility property to add a single `MenuEvent.MENU_SELECTED` event
**/
@:event(MenuEvent.MENU_SELECTED) public var onMenuSelected:MenuEvent->Void;
private override function onThemeChanged() {
super.onThemeChanged();
var builder:Builder = cast(this._compositeBuilder, Builder);
builder.onThemeChanged();
}
}
//***********************************************************************************************************
// Behaviours
//***********************************************************************************************************
@:dox(hide) @:noCompletion
@:access(haxe.ui.core.Component)
private class CurrentIndexBehaviour extends DataBehaviour {
public override function set(value:Variant) {
var _menu:Menu = cast _component;
var menuItemCount = _menu.findComponents(MenuItem, 1).length;
if (value >= menuItemCount) {
value = 0;
}
super.set(value);
}
private override function validateData() {
var _menu:Menu = cast _component;
var items = _menu.findComponents(MenuItem, 1);
var menuItemIndex:Int = _value;
if (menuItemIndex > 0) {
_menu.currentItem = items[menuItemIndex];
}
}
}
@:dox(hide) @:noCompletion
@:access(haxe.ui.core.Component)
private class CurrentItemBehaviour extends DataBehaviour {
private override function validateData() {
var _menu:Menu = cast _component;
var menuItem:MenuItem = cast _value.toComponent();
var index = _menu.findComponents(MenuItem, 1).indexOf(menuItem);
_menu.currentIndex = index;
for (child in _menu.childComponents) {
//child.removeClass(":hover", true, true);
}
var item:Component = _value;
if (menuItem != null) {
//menuItem.addClass(":hover", true, true);
}
}
}
//***********************************************************************************************************
// Events
//***********************************************************************************************************
@:dox(hide) @:noCompletion
@:access(haxe.ui.core.Component)
@:access(haxe.ui.containers.menus.Builder)
class MenuEvents extends haxe.ui.events.Events {
private var _menu:Menu;
public var currentSubMenu:Menu = null;
public var parentMenu:Menu = null;
private static inline var TIME_MOUSE_OPENS_MS:Int = 400;
private var _timer:Timer = null;
public var button:Button = null;
public function new(menu:Menu) {
super(menu);
_menu = menu;
}
public override function register() {
if (!hasEvent(MouseEvent.MOUSE_OVER, onMouseOver)) {
registerEvent(MouseEvent.MOUSE_OVER, onMouseOver);
}
if (!hasEvent(MouseEvent.MOUSE_OUT, onMouseOut)) {
registerEvent(MouseEvent.MOUSE_OUT, onMouseOut);
}
for (child in _menu.childComponents) {
if ((child is MenuItem)) {
var item:MenuItem = cast(child, MenuItem);
if (!item.hasEvent(MouseEvent.CLICK, onItemClick)) {
item.registerEvent(MouseEvent.CLICK, onItemClick);
}
if (!item.hasEvent(MouseEvent.MOUSE_OVER, onItemMouseOver)) {
item.registerEvent(MouseEvent.MOUSE_OVER, onItemMouseOver);
}
if (!item.hasEvent(MouseEvent.MOUSE_OUT, onItemMouseOut)) {
item.registerEvent(MouseEvent.MOUSE_OUT, onItemMouseOut);
}
}
}
if (!hasEvent(UIEvent.HIDDEN, onHidden)) {
registerEvent(UIEvent.HIDDEN, onHidden);
}
if (!hasEvent(UIEvent.SHOWN, onShown)) {
registerEvent(UIEvent.SHOWN, onShown);
}
}
public override function unregister() {
unregisterEvent(MouseEvent.MOUSE_OVER, onMouseOver);
unregisterEvent(MouseEvent.MOUSE_OUT, onMouseOut);
for (child in _menu.childComponents) {
child.unregisterEvent(MouseEvent.CLICK, onItemClick);
child.unregisterEvent(MouseEvent.MOUSE_OVER, onItemMouseOver);
child.unregisterEvent(MouseEvent.MOUSE_OUT, onItemMouseOut);
}
unregisterEvent(UIEvent.HIDDEN, onHidden);
unregisterEvent(UIEvent.SHOWN, onShown);
}
public override function onDispose() {
removeScreenMouseDown();
}
private var _over:Bool = false;
private function onMouseOver(event:MouseEvent) {
_over = true;
}
private function onMouseOut(event:MouseEvent) {
_over = false;
}
private function onItemClick(event:MouseEvent) {
var item:MenuItem = cast(event.target, MenuItem);
if (!item.expandable) {
var event = new MenuEvent(MenuEvent.MENU_SELECTED);
event.menu = _menu;
event.menuItem = item;
// we'll add a delay of 100ms here because it "feels nicer" for the menu to
// not just instantly disappear - especially in the case of checkbox menu items
Timer.delay(function() {
// however, its possible that by the time that timer has ticked the menu
// has already been destroyed by other means, so lets just make sure
// that isnt the case
if (@:privateAccess _menu._isDisposed) {
return;
}
findRootMenu().dispatch(event);
if (_menu.menuBar == null) {
var beforeCloseEvent = new UIEvent(UIEvent.BEFORE_CLOSE);
beforeCloseEvent.relatedComponent = item;
findRootMenu().dispatch(beforeCloseEvent);
if (beforeCloseEvent.canceled) {
return;
}
hideMenu();
removeScreenMouseDown();
}
_menu.dispatch(new UIEvent(UIEvent.CLOSE));
}, 100);
}
}
public var lastEventSubMenu:MouseEvent = null;
private function onItemMouseOver(event:MouseEvent) {
var builder:Builder = cast(_menu._compositeBuilder, Builder);
var subMenus:Map<MenuItem, Menu> = builder._subMenus;
var item:MenuItem = cast(event.target, MenuItem);
for (child in _menu.childComponents) {
if (child != item) {
child.removeClass(":hover", true, true);
}
}
if (parentMenu != null) {
// so that's is always the parent menu that is visually selected
// even if you have previously hovered over parent's siblings.
var menuItem:MenuItem = null;
for (mi => menu in cast(parentMenu._compositeBuilder, Builder)._subMenus) {
if (_menu == menu) menuItem = mi;
}
parentMenu.currentItem = menuItem;
}
if (_timer != null) {
_timer.stop();
_timer = null;
}
if (subMenus.get(item) != null) {
_menu.currentItem = item;
lastEventSubMenu = event;
_timer = new Timer(TIME_MOUSE_OPENS_MS, function() {
showSubMenu(cast(subMenus.get(item), Menu), item);
_timer.stop();
_timer = null;
});
} else {
if (currentSubMenu != null) {
if (!isMouseAimingForSubMenu(event)) {
hideCurrentSubMenu();
lastEventSubMenu = null;
} else {
_timer = new Timer(TIME_MOUSE_OPENS_MS, function f() {
hideCurrentSubMenu();
_timer.stop();
_timer = null;
});
}
lastEventSubMenu = event;
}
}
}
private function isMouseAimingForSubMenu(event:MouseEvent) {
// We check if the mouse is moving towards the submenu
// by looking if it's inside the triangle formed by his last position
// and the top and bottom of the submenu
if (lastEventSubMenu == null) return true;
var vX = lastEventSubMenu.screenX;
var vY = lastEventSubMenu.screenY;
var v2X = currentSubMenu.screenLeft;
var v2Y = currentSubMenu.screenTop;
var v3X = v2X;
var v3Y = currentSubMenu.screenTop + currentSubMenu.height;
// https://stackoverflow.com/questions/2049582/how-to-determine-if-a-point-is-in-a-2d-triangle
inline function sign (px:Float, py:Float, p2x:Float, p2y:Float, p3x:Float, p3y:Float)
{
return (px - p3x) * (p2y - p3y) - (p2x - p3x) * (py - p3y);
}
var d1 = sign(event.screenX, event.screenY, vX, vY, v2X, v2Y);
var d2 = sign(event.screenX, event.screenY, v2X, v2Y, v3X, v3Y);
var d3 = sign(event.screenX, event.screenY, v3X, v3Y, vX, vY);
var hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
var hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
private function onItemMouseOut(event:MouseEvent) {
if (_timer != null) {
_timer.stop();
_timer = null;
}
if (currentSubMenu != null) {
_menu.currentItem.addClass(":hover", true, true);
return;
} else {
_menu.currentItem = null;
}
}
private function showSubMenu(subMenu:Menu, source:MenuItem) {
hideCurrentSubMenu();
subMenu.menuStyleNames = _menu.menuStyleNames;
subMenu.addClass(_menu.menuStyleNames);
var componentOffset = source.getComponentOffset();
var left = source.screenLeft + source.actualComponentWidth + componentOffset.x;
var top = source.screenTop;
subMenu.handleVisibility(false);
Screen.instance.addComponent(subMenu);
subMenu.validateNow();
if (left + subMenu.actualComponentWidth > Screen.instance.width) {
left = source.screenLeft - subMenu.actualComponentWidth;
}
var offsetX:Float = 0;
var offsetY:Float = 0;
if (subMenu.style != null) {
if (subMenu.style.paddingLeft > 1) {
//offsetX = subMenu.style.paddingLeft - 1;
offsetX = subMenu.style.paddingLeft / 2;
} else {
offsetX = 0;
}
if (subMenu.style.paddingTop > 1) {
offsetY = subMenu.style.paddingTop - 1;
} else {
offsetY = 1;
}
}
subMenu.left = left + offsetX;
subMenu.top = top - offsetY;
currentSubMenu = subMenu;
Toolkit.callLater(() -> {
subMenu.handleVisibility(true);
});
}
private function hideMenu() {
var root = findRootMenu();
if (root == null) {
return;
}
var events:MenuEvents = cast(root._internalEvents, MenuEvents);
if (events.button == null) {
for (child in root.childComponents) {
child.removeClass(":hover", true, true);
}
events.hideCurrentSubMenu();
Screen.instance.removeComponent(root, false);
}
}
private function hideCurrentSubMenu() {
if (currentSubMenu == null) {
return;
}
if (currentSubMenu._isDisposed) { // sub menu could have already been disposed of
return;
}
for (child in currentSubMenu.childComponents) {
child.removeClass(":hover", true, true);
}
var subMenuEvents:MenuEvents = cast(currentSubMenu._internalEvents, MenuEvents);
subMenuEvents.hideCurrentSubMenu();
Screen.instance.removeComponent(currentSubMenu, false);
currentSubMenu = null;
}
private function onHidden(event:UIEvent) {
for (child in _menu.childComponents) {
child.removeClass(":hover", true, true);
}
hideCurrentSubMenu();
}
private function onShown(event:UIEvent) {
addScreenMouseDown();
}
public function findRootMenu():Menu {
var root:Menu = null;
var ref = _menu;
while (ref != null) {
var events:MenuEvents = cast(ref._internalEvents, MenuEvents);
if (events.parentMenu == null) {
root = events._menu;
break;
}
ref = events.parentMenu;
}
return root;
}
public var hasScreenMouseDown:Bool = false;
private function addScreenMouseDown() {
var root = findRootMenu();
var events:MenuEvents = cast(root._internalEvents, MenuEvents);
if (events.hasScreenMouseDown == false) {
events.hasScreenMouseDown = true;
Screen.instance.registerEvent(MouseEvent.MOUSE_DOWN, onScreenMouseDown);
Screen.instance.registerEvent(MouseEvent.RIGHT_MOUSE_DOWN, onScreenMouseDown);
}
}
private function removeScreenMouseDown() {
var root = findRootMenu();
var events:MenuEvents = cast(root._internalEvents, MenuEvents);
events.hasScreenMouseDown = false;
Screen.instance.unregisterEvent(MouseEvent.MOUSE_DOWN, onScreenMouseDown);
Screen.instance.unregisterEvent(MouseEvent.RIGHT_MOUSE_DOWN, onScreenMouseDown);
}
private function onScreenMouseDown(event:MouseEvent) {
var close:Bool = true;
if (_menu.hitTest(event.screenX, event.screenY)) {
close = false;
} else if (button != null && button.hitTest(event.screenX, event.screenY)) {
close = false;
} else {
var ref = _menu;
var refEvents:MenuEvents = cast(ref._internalEvents, MenuEvents);
var refSubMenu = refEvents.currentSubMenu;
while (refSubMenu != null) {
if (refSubMenu.hitTest(event.screenX, event.screenY)) {
close = false;
break;
}
ref = refSubMenu;
refEvents = cast(ref._internalEvents, MenuEvents);
refSubMenu = refEvents.currentSubMenu;
}
}
if (close) {
hideMenu();
removeScreenMouseDown();
_menu.dispatch(new UIEvent(UIEvent.CLOSE));
}
}
}
//***********************************************************************************************************
// Composite Builder
//***********************************************************************************************************
@:dox(hide) @:noCompletion
@:access(haxe.ui.core.Component)
private class Builder extends CompositeBuilder {
private var _menu:Menu;
private var _subMenus:Map<MenuItem, Menu> = new Map<MenuItem, Menu>();
public function new(menu:Menu) {
super(menu);
_menu = menu;
}
@:access(haxe.ui.core.Screen)
public function onThemeChanged() {
for (menuItem in _subMenus.keys()) {
var menu = _subMenus.get(menuItem);
Screen.instance.invalidateChildren(menu);
Screen.instance.onThemeChangedChildren(menu);
}
}
public override function addComponent(child:Component):Component {
if ((child is Menu)) {
var menu = cast(child, Menu);
var item = new MenuItem();
item.id = child.id + "Item";
item.text = child.text;
item.icon = menu.icon;
item.tooltip = child.tooltip;
item.expandable = true;
_menu.addComponent(item);
cast(menu._internalEvents, MenuEvents).parentMenu = _menu;
menu.registerEvent(UIEvent.PROPERTY_CHANGE, onMenuPropertyChanged);
_subMenus.set(item, menu);
return child;
}
return null;
}
private function onMenuPropertyChanged(event:UIEvent) {
if (event.data == "text") {
var menu:Menu = cast(event.target, Menu);
for (item in _subMenus.keys()) {
var subMenu = _subMenus.get(item);
if (subMenu == menu) {
item.text = event.target.text;
break;
}
}
}
}
public override function onComponentAdded(child:Component) {
if ((child is Menu) || (child is MenuItem)) {
_menu.registerInternalEvents(true);
}
}
public override function findComponent<T:Component>(criteria:String, type:Class<T>, recursive:Null<Bool>, searchType:String):Null<T> {
var match = super.findComponent(criteria, type, recursive, searchType);
if (match == null) {
for (menu in _subMenus) {
match = menu.findComponent(criteria, type, recursive, searchType);
if (menu.matchesSearch(criteria, type, searchType)) {
return cast menu;
} else {
match = menu.findComponent(criteria, type, recursive, searchType);
}
if (match != null) {
break;
}
}
}
return cast match;
}
public override function findComponents<T:Component>(styleName:String = null, type:Class<T> = null, maxDepth:Int = 5):Array<T> {
var r:Array<T> = [];
for (menu in _subMenus) {
var match = true;
if (styleName != null && menu.hasClass(styleName) == false) {
match = false;
}
if (type != null && isOfType(menu, type) == false) {
match = false;
}
if (match == true) {
r.push(cast menu);
} else {
var childArray = menu.findComponents(styleName, type, maxDepth);
for (c in childArray) { // r.concat caused issues here on hxcpp
r.push(c);
}
}
}
return r;
}
public override function destroy() {
super.destroy();
if (_menu != null && _menu._isDisposed == false) {
Screen.instance.removeComponent(_menu);
}
for (subMenu in _subMenus) {
Screen.instance.removeComponent(subMenu);
}
}
public override function hide() {
Screen.instance.removeComponent(_menu, false);
return true;
}
public override function show() {
Screen.instance.addComponent(_menu);
return true;
}
}
private class Layout extends VerticalLayout {
private override function resizeChildren() {
if (!_component.autoWidth) {
for (child in component.childComponents) {
if (child.includeInLayout == false) {
continue;
}
if (child.percentWidth == null) {
child.percentWidth = 100;
}
}
super.resizeChildren();
} else {
var usableSize:Size = usableSize;
var biggest:Float = 0;
for (child in component.childComponents) {
if (child.includeInLayout == false) {
continue;
}
if (child.width <= 0) {
child.validateNow();
}
if (child.width > biggest) {
biggest = child.width;
}
}
for (child in component.childComponents) {
if (child.includeInLayout == false) {
continue;
}
var cx:Null<Float> = null;
cx = 100;
child.width = biggest;
}
}
}
}