//
// Triple Play - utilities for use in PlayN-based games
// Copyright (c) 2011-2014, Three Rings Design, Inc. - All rights reserved.
// http://github.com/threerings/tripleplay/blob/master/LICENSE
package tripleplay.ui;
import pythagoras.f.Dimension;
import pythagoras.f.IDimension;
import pythagoras.f.MathUtil;
import pythagoras.f.Point;
import pythagoras.f.Rectangle;
import react.Signal;
import react.SignalView;
import react.Slot;
import react.UnitSlot;
import react.ValueView;
import playn.core.GroupLayer;
import playn.core.Layer;
import playn.core.PlayN;
import tripleplay.ui.util.Insets;
import tripleplay.util.Ref;
/**
* The root of the interface element hierarchy. See {@link Widget} for the root of all interactive
* elements, and {@link Container} for the root of all container elements.
*
* @param T used as a "self" type; when subclassing {@code Element}, T must be the type of the
* subclass.
*/
public abstract class Element<T extends Element<T>>
{
/** The layer associated with this element. */
public final GroupLayer layer = createLayer();
protected Element () {
// optimize hit testing by checking our bounds first
layer.setHitTester(new Layer.HitTester() {
public Layer hitTest (Layer layer, Point p) {
Layer hit = null;
if (isVisible() && contains(p.x, p.y)) {
if (isSet(Flag.HIT_DESCEND)) hit = layer.hitTestDefault(p);
if (hit == null && isSet(Flag.HIT_ABSORB)) hit = layer;
}
return hit;
}
@Override public String toString () {
return "HitTester for " + Element.this;
}
});
// descend by default
set(Flag.HIT_DESCEND, true);
}
/**
* Returns this element's x offset relative to its parent.
*/
public float x () {
return layer.tx();
}
/**
* Returns this element's y offset relative to its parent.
*/
public float y () {
return layer.ty();
}
/**
* Returns the width and height of this element's bounds.
*/
public IDimension size () {
return _size;
}
/**
* Writes the location of this element (relative to its parent) into the supplied point.
* @return {@code loc} for convenience.
*/
public Point location (Point loc) {
return loc.set(x(), y());
}
/**
* Writes the current bounds of this element into the supplied bounds.
* @return {@code bounds} for convenience.
*/
public Rectangle bounds (Rectangle bounds) {
bounds.setBounds(x(), y(), _size.width, _size.height);
return bounds;
}
/**
* Returns the parent of this element, or null.
*/
public Container<?> parent () {
return _parent;
}
/**
* Returns a signal that will dispatch when this element is added or removed from the
* hierarchy. The emitted value is true if the element was just added to the hierarchy, false
* if removed.
*/
public SignalView<Boolean> hierarchyChanged () {
if (_hierarchyChanged == null) _hierarchyChanged = Signal.create();
return _hierarchyChanged;
}
/**
* Returns the styles configured on this element.
*/
public Styles styles () {
return _styles;
}
/**
* Configures the styles for this element. Any previously configured styles are overwritten.
* @return this element for convenient call chaining.
*/
public T setStyles (Styles styles) {
_styles = styles;
clearLayoutData();
invalidate();
return asT();
}
/**
* Configures styles for this element (in the DEFAULT mode). Any previously configured styles
* are overwritten.
* @return this element for convenient call chaining.
*/
public T setStyles (Style.Binding<?>... styles) {
return setStyles(Styles.make(styles));
}
/**
* Adds the supplied styles to this element. Where the new styles overlap with existing styles,
* the new styles are preferred, but non-overlapping old styles are preserved.
* @return this element for convenient call chaining.
*/
public T addStyles (Styles styles) {
_styles = _styles.merge(styles);
clearLayoutData();
invalidate();
return asT();
}
/**
* Adds the supplied styles to this element (in the DEFAULT mode). Where the new styles overlap
* with existing styles, the new styles are preferred, but non-overlapping old styles are
* preserved.
* @return this element for convenient call chaining.
*/
public T addStyles (Style.Binding<?>... styles) {
return addStyles(Styles.make(styles));
}
/**
* Returns {@code this} cast to {@code T}.
*/
@SuppressWarnings({"unchecked", "cast"}) protected T asT () {
return (T)this;
}
/**
* Returns whether this element is enabled.
*/
public boolean isEnabled () {
return isSet(Flag.ENABLED);
}
/**
* Enables or disables this element. Disabled elements are not interactive and are usually
* rendered so as to communicate this state to the user.
*/
public T setEnabled (boolean enabled) {
if (enabled != isEnabled()) {
set(Flag.ENABLED, enabled);
clearLayoutData();
invalidate();
}
return asT();
}
/**
* Returns a slot which can be used to wire the enabled status of this element to a {@link
* react.Signal} or {@link react.Value}.
*/
public Slot<Boolean> enabledSlot () {
return new Slot<Boolean>() {
@Override public void onEmit (Boolean value) {
setEnabled(value);
}
};
}
/**
* Binds the enabledness of this element to the supplied value view. The current enabledness will
* be adjusted to match the state of {@code isEnabled}.
*/
public T bindEnabled (ValueView<Boolean> isEnabled) {
isEnabled.connectNotify(enabledSlot());
return asT();
}
/**
* Returns whether this element is visible.
*/
public boolean isVisible () {
return isSet(Flag.VISIBLE);
}
/**
* Configures whether this element is visible. An invisible element is not rendered and
* consumes no space in a group.
*/
public T setVisible (boolean visible) {
if (visible != isVisible()) {
set(Flag.VISIBLE, visible);
layer.setVisible(visible);
invalidate();
}
return asT();
}
/**
* Returns a slot which can be used to wire the visible status of this element to a {@link
* react.Signal} or {@link react.Value}.
*/
public Slot<Boolean> visibleSlot () {
return new Slot<Boolean>() {
@Override public void onEmit (Boolean value) {
setVisible(value);
}
};
}
/**
* Binds the visibility of this element to the supplied value view. The current visibility will
* be adjusted to match the state of {@code isVisible}.
*/
public T bindVisible (ValueView<Boolean> isVisible) {
isVisible.connectNotify(visibleSlot());
return asT();
}
/**
* Returns true only if this element and all its parents' {@link #isVisible()} return true.
*/
public boolean isShowing () {
Container<?> parent;
return isVisible() && ((parent = parent()) != null) && parent.isShowing();
}
/**
* Returns the layout constraint configured on this element, or null.
*/
public Layout.Constraint constraint () {
return _constraint;
}
/**
* Configures the layout constraint on this element.
* @return this element for call chaining.
*/
public T setConstraint (Layout.Constraint constraint) {
if (constraint != null) constraint.setElement(this);
_constraint = constraint;
invalidate();
return asT();
}
/**
* Returns true if this element is part of an interface heirarchy.
*/
public boolean isAdded () {
return root() != null;
}
/**
* Returns the class of this element for use in computing its style. By default this is the
* actual class, but you may wish to, for example, extend {@link Label} with some customization
* and override this method to return {@code Label.class} so that your extension has the same
* styles as Label applied to it.
*
* Concrete Element implementations should return the actual class instance instead of
* getClass(). Returning getClass() means that further subclasses will lose all styles applied
* to this implementation, probably unintentionally.
*/
protected abstract Class<?> getStyleClass ();
/**
* Called when this element is added to a parent element. If the parent element is already
* added to a hierarchy with a {@link Root}, this will immediately be followed by a call to
* {@link #wasAdded}, otherwise the {@link #wasAdded} call will come later when the parent is
* added to a root.
*/
protected void wasParented (Container<?> parent) {
_parent = parent;
}
/**
* Called when this element is removed from its direct parent. If the element was removed from
* a parent that was connected to a {@link Root}, a call to {@link #wasRemoved} will
* immediately follow. Otherwise no call to {@link #wasRemoved} will be made.
*/
protected void wasUnparented () {
_parent = null;
}
/**
* Called when this element (or its parent element) was added to an interface hierarchy
* connected to a {@link Root}. The element will subsequently be validated and displayed
* (assuming it's visible).
*/
protected void wasAdded () {
if (_hierarchyChanged != null) _hierarchyChanged.emit(Boolean.TRUE);
invalidate();
set(Flag.IS_ADDING, false);
}
/**
* Called when this element (or its parent element) was removed from the interface hierarchy.
* Also, if the element was removed directly from its parent, then the layer is orphaned prior
* to this call. Furthermore, if the element is being destroyed (see {@link
* Container.Mutable#destroy} and other methods), the destruction of the layer will occur
* <b>after</b> this method returns and the {@link #willDestroy()} method returns true. This
* allows subclasses to manage resources as needed. <p><b>NOTE</b>: the base class method must
* <b>always</b> be called for correct operation.</p>
*/
protected void wasRemoved () {
_bginst.clear();
if (_hierarchyChanged != null) _hierarchyChanged.emit(Boolean.FALSE);
set(Flag.IS_REMOVING, false);
}
/**
* Returns true if the supplied, element-relative, coordinates are inside our bounds.
*/
protected boolean contains (float x, float y) {
return !(x < 0 || x > _size.width || y < 0 || y > _size.height);
}
/**
* Returns whether this element is selected. This is only applicable for elements that maintain
* a selected state, but is used when computing styles for all elements (it is assumed that an
* element that maintains no selected state will always return false from this method).
* Elements that do maintain a selected state should override this method and expose it as
* public.
*/
protected boolean isSelected () {
return isSet(Flag.SELECTED);
}
/**
* An element should call this method when it knows that it has changed in such a way that
* requires it to recreate its visualization.
*/
protected void invalidate () {
// note that our preferred size and background are no longer valid
_preferredSize = null;
if (isSet(Flag.VALID)) {
set(Flag.VALID, false);
// invalidate our parent if we've got one
if (_parent != null) {
_parent.invalidate();
}
}
}
/**
* Gets a new slot which will invoke {@link #invalidate()} when emitted.
*/
protected UnitSlot invalidateSlot () {
return invalidateSlot(false);
}
/**
* Gets a new slot which will invoke {@link #invalidate()}.
* @param styles if set, the slot will also call {@link #clearLayoutData()} when emitted
*/
protected UnitSlot invalidateSlot (final boolean styles) {
return new UnitSlot() {
@Override public void onEmit () {
invalidate();
if (styles) clearLayoutData();
}
};
}
/**
* Does whatever this element needs to validate itself. This may involve recomputing
* visualizations, or laying out children, or anything else.
*/
protected void validate () {
if (!isSet(Flag.VALID)) {
layout();
set(Flag.VALID, true);
}
}
/**
* Returns the root of this element's hierarchy, or null if the element is not currently added
* to a hierarchy.
*/
protected Root root () {
return (_parent == null) ? null : _parent.root();
}
/**
* Returns whether the specified flag is set.
*/
protected boolean isSet (Flag flag) {
return (flag.mask & _flags) != 0;
}
/**
* Sets or clears the specified flag.
*/
protected void set (Flag flag, boolean on) {
if (on) {
_flags |= flag.mask;
} else {
_flags &= ~flag.mask;
}
}
/**
* Returns this element's preferred size, potentially recomputing it if needed.
*
* @param hintX if non-zero, an indication that the element will be constrained in the x
* direction to the specified width.
* @param hintY if non-zero, an indication that the element will be constrained in the y
* direction to the specified height.
*/
protected IDimension preferredSize (float hintX, float hintY) {
if (_preferredSize == null) {
if (_constraint != null) {
hintX = _constraint.adjustHintX(hintX);
hintY = _constraint.adjustHintY(hintY);
}
Dimension psize = computeSize(hintX, hintY);
if (_constraint != null) _constraint.adjustPreferredSize(psize, hintX, hintY);
// round our preferred size up to the nearest whole number; if we allow it to remain
// fractional, we can run into annoying layout problems where floating point rounding
// error causes a tiny fraction of a pixel to be shaved off of the preferred size of a
// text widget, causing it to wrap its text differently and hosing the layout
psize.width = MathUtil.iceil(psize.width);
psize.height = MathUtil.iceil(psize.height);
_preferredSize = psize;
}
return _preferredSize;
}
/**
* Configures the location of this element, relative to its parent.
*/
protected void setLocation (float x, float y) {
layer.setTranslation(MathUtil.ifloor(x), MathUtil.ifloor(y));
}
/**
* Configures the size of this widget.
*/
protected T setSize (float width, float height) {
boolean changed = _size.width != width || _size.height != height;
_size.setSize(width, height);
// if we have a cached preferred size and this size differs from it, we need to clear our
// layout data as it may contain computations specific to our preferred size
if (_preferredSize != null && !_size.equals(_preferredSize)) clearLayoutData();
if (changed) invalidate();
return asT();
}
/**
* Resolves the value for the supplied style. See {@link Styles#resolveStyle} for the gritty
* details.
*/
protected <V> V resolveStyle (Style<V> style) {
return Styles.resolveStyle(this, style);
}
/**
* Recomputes this element's preferred size.
*
* @param hintX if non-zero, an indication that the element will be constrained in the x
* direction to the specified width.
* @param hintY if non-zero, an indication that the element will be constrained in the y
* direction to the specified height.
*/
protected Dimension computeSize (float hintX, float hintY) {
LayoutData ldata = _ldata = createLayoutData(hintX, hintY);
Insets insets = ldata.bg.insets;
Dimension size = ldata.computeSize(hintX - insets.width(), hintY - insets.height());
return insets.addTo(size);
}
/**
* Handles common element layout (background), then calls {@link LayoutData#layout} to do the
* actual layout.
*/
protected void layout () {
if (!isVisible()) return;
float width = _size.width, height = _size.height;
LayoutData ldata = (_ldata != null) ? _ldata : createLayoutData(width, height);
// if we have a non-matching background, destroy it (note that if we don't want a bg, any
// existing bg will necessarily be invalid)
Background.Instance bginst = _bginst.get();
boolean bgok = (bginst != null && bginst.owner() == ldata.bg &&
bginst.size.equals(_size));
if (!bgok) _bginst.clear();
// if we want a background and don't already have one, create it
if (width > 0 && height > 0 && !bgok) {
bginst = _bginst.set(ldata.bg.instantiate(_size));
bginst.addTo(layer, 0, 0, 0);
}
// do our actual layout
Insets insets = ldata.bg.insets;
ldata.layout(insets.left(), insets.top(),
width - insets.width(), height - insets.height());
// finally clear our cached layout data
clearLayoutData();
}
/**
* Creates the layout data record used by this element. This record temporarily holds resolved
* style information between the time that an element has its preferred size computed, and the
* time that the element is subsequently laid out. Note: {@code hintX} and {@code hintY} <em>do
* not</em> yet have the background insets subtracted from them, because the creation of the
* LayoutData is what resolves the background in the first place.
*/
protected abstract LayoutData createLayoutData (float hintX, float hintY);
/**
* Clears out cached layout data. This can be called by methods that change the configuration
* of the element when they know it will render pre-computed layout info invalid.
*/
protected void clearLayoutData () {
_ldata = null;
}
/**
* Creates the layer to be used by this element. Subclasses may override to use a clipped one.
*/
protected GroupLayer createLayer () {
return PlayN.graphics().createGroupLayer();
}
/**
* Tests if this element is about to be destroyed. Elements are destroyed via a call to one of
* the "destroy" methods such as {@link Container.Mutable#destroy(Element)}. This allows
* subclasses to manage resources appropriately during their implementation of {@link
* #wasRemoved}, for example clearing a child cache. <p>NOTE: at the expense of slight semantic
* dissonance, the flag is not cleared after destruction</p>
*/
protected boolean willDestroy () {
return isSet(Flag.WILL_DESTROY);
}
/**
* Tests if this element is scheduled to be removed from a root hierarchy.
*/
protected final boolean willRemove () {
return isSet(Flag.IS_REMOVING) || (_parent != null && _parent.willRemove());
}
/**
* Tests if this element is scheduled to be added to a root hierarchy.
*/
protected final boolean willAdd () {
return isSet(Flag.IS_ADDING) || (_parent != null && _parent.willAdd());
}
protected abstract class BaseLayoutData {
/**
* Rebuilds this element's visualization. Called when this element's size has changed. In
* the case of groups, this will relayout its children, in the case of widgets, this will
* rerender the widget.
*/
public void layout (float left, float top, float width, float height) {
// noop!
}
}
protected abstract class LayoutData extends BaseLayoutData {
public final Background bg = resolveStyle(Style.BACKGROUND);
/**
* Computes this element's preferred size, given the supplied hints. The background insets
* will be automatically added to the returned size.
*/
public abstract Dimension computeSize (float hintX, float hintY);
}
/** Ways in which a preferred and an original dimension can be "taken" to produce a result.
* The name is supposed to be readable in context and compact, for example
* {@code new SizableLayoutData(...).forWidth(Take.MAX).forHeight(Take.MIN, 200)}. */
protected enum Take
{
/** Uses the maximum of the preferred size and original. */
MAX {
@Override public float apply (float preferred, float original) {
return Math.max(preferred, original);
}
},
/** Uses the minimum of the preferred size and original. */
MIN {
@Override public float apply (float preferred, float original) {
return Math.min(preferred, original);
}
},
/** Uses the preferred size if non-zero, otherwise the original. This is the default. */
PREFERRED_IF_SET {
@Override public float apply (float preferred, float original) {
return preferred == 0 ? original : preferred;
}
};
public abstract float apply (float preferred, float original);
}
/**
* A layout data that will delegate to another layout data instance, but alter the size
* computation to optionally use fixed values.
*/
protected class SizableLayoutData extends LayoutData {
/**
* Creates a new layout with the given delegates and size.
* @param layoutDelegate the delegate to use during layout. May be null if the element
* has no layout
* @param sizeDelegate the delegate to use during size computation. May be null if the
* size will be completely specified by {@code prefSize}
* @param prefSize overrides the size computation. The width and/or height may be zero,
* which indicates the {@code sizeDelegate}'s result should be used for that axis. Passing
* {@code null} is equivalent to passing a 0x0 dimension
*/
public SizableLayoutData (BaseLayoutData layoutDelegate, LayoutData sizeDelegate,
IDimension prefSize) {
this.layoutDelegate = layoutDelegate;
this.sizeDelegate = sizeDelegate;
if (prefSize != null) {
prefWidth = prefSize.width();
prefHeight = prefSize.height();
} else {
prefWidth = prefHeight = 0;
}
}
/**
* Creates a new layout that will defer to the given delegate for layout and size. This is
* equivalent to {@code SizableLayoutData(delegate, delegate, prefSize)}.
* @see #SizableLayoutData(BaseLayoutData, LayoutData, IDimension)
*/
public SizableLayoutData (LayoutData delegate, IDimension prefSize) {
this.layoutDelegate = delegate;
this.sizeDelegate = delegate;
if (prefSize != null) {
prefWidth = prefSize.width();
prefHeight = prefSize.height();
} else {
prefWidth = prefHeight = 0;
}
}
/**
* Sets the way in which widths are combined to calculate the resulting preferred size.
* For example, {@code new SizeableLayoutData(...).forWidth(Take.MAX)}.
*/
public SizableLayoutData forWidth (Take fn) {
widthFn = fn;
return this;
}
/**
* Sets the preferred width and how it should be combined with the delegate's preferred
* width. For example, {@code new SizeableLayoutData(...).forWidth(Take.MAX, 250)}.
*/
public SizableLayoutData forWidth (Take fn, float pref) {
widthFn = fn;
prefWidth = pref;
return this;
}
/**
* Sets the way in which heights are combined to calculate the resulting preferred size.
* For example, {@code new SizeableLayoutData(...).forHeight(Take.MAX)}.
*/
public SizableLayoutData forHeight (Take fn) {
heightFn = fn;
return this;
}
/**
* Sets the preferred height and how it should be combined with the delegate's preferred
* height. For example, {@code new SizeableLayoutData(...).forHeight(Take.MAX, 250)}.
*/
public SizableLayoutData forHeight (Take fn, float pref) {
heightFn = fn;
prefHeight = pref;
return this;
}
@Override public Dimension computeSize (float hintX, float hintY) {
// hint the delegate with our preferred width or height or both,
// then swap in our preferred function on that (min, max, or subclass)
return adjustSize(sizeDelegate == null ? new Dimension(prefWidth, prefHeight) :
sizeDelegate.computeSize(resolveHintX(hintX), resolveHintY(hintY)));
}
@Override public void layout (float left, float top, float width, float height) {
if (layoutDelegate != null) layoutDelegate.layout(left, top, width, height);
}
/**
* Refines the given x hint for the delegate to consume. By default uses our configured
* preferred width if not zero, otherwise the passed-in x hint.
*/
protected float resolveHintX (float hintX) {
return select(prefWidth, hintX);
}
/**
* Refines the given y hint for the delegate to consume. By default uses our configured
* preferred height if not zero, otherwise the passed-in y hint.
*/
protected float resolveHintY (float hintY) {
return select(prefHeight, hintY);
}
/**
* Adjusts the dimension computed by the delegate to get the final preferred size. By
* default, uses the previously configured {@link Take} values.
*/
protected Dimension adjustSize (Dimension dim) {
dim.width = widthFn.apply(prefWidth, dim.width);
dim.height = heightFn.apply(prefHeight, dim.height);
return dim;
}
protected float select (float pref, float base) {
return pref == 0 ? base : pref;
}
protected final BaseLayoutData layoutDelegate;
protected final LayoutData sizeDelegate;
protected float prefWidth, prefHeight;
protected Take widthFn = Take.PREFERRED_IF_SET, heightFn = Take.PREFERRED_IF_SET;
}
protected int _flags = Flag.VISIBLE.mask | Flag.ENABLED.mask;
protected Container<?> _parent;
protected Dimension _preferredSize;
protected Dimension _size = new Dimension();
protected Styles _styles = Styles.none();
protected Layout.Constraint _constraint;
protected Signal<Boolean> _hierarchyChanged;
protected LayoutData _ldata;
protected final Ref<Background.Instance> _bginst = Ref.<Background.Instance>create(null);
protected static enum Flag {
VALID(1 << 0), ENABLED(1 << 1), VISIBLE(1 << 2), SELECTED(1 << 3), WILL_DESTROY(1 << 4),
HIT_DESCEND(1 << 5), HIT_ABSORB(1 << 6), IS_REMOVING(1 << 7), IS_ADDING(1 << 8);
public final int mask;
Flag (int mask) {
this.mask = mask;
}
}
}