Package tripleplay.ui

Source Code of tripleplay.ui.HistoryGroup

//
// 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 java.util.ArrayList;
import java.util.List;

import pythagoras.f.Dimension;
import pythagoras.f.IRectangle;
import pythagoras.f.Rectangle;
import tripleplay.ui.Composite;
import tripleplay.ui.Container;
import tripleplay.ui.Element;
import tripleplay.ui.Group;
import tripleplay.ui.Interface;
import tripleplay.ui.Layout;
import tripleplay.ui.Scroller;
import tripleplay.ui.layout.AxisLayout;
import static tripleplay.ui.Log.log;

/**
* A scrolling vertical display, optimized for showing potentially very long lists such as
* a chat log. Supports:<ul>
* <li>addition of new elements on the end</li>
* <li>pruning old elements from the beginning</li>
* <li>progressive rendering of newly visible items in the list</li>
* <li>automatically keeping the last item visible</li>
* <li>purging of old rendered elements that are no longer visible
* </ul>
* Items are stored in a backing array. Each entry in the array may or may not have a
* corresponding Element visible (presuming that the rendering and storage of elements is
* expensive). When the user scrolls, entries are rendered on demand using
* {@link HistoryGroup#render(Entry)}. Entries that are not visible use an estimated size
* for layout purposes. This of course may produce some artifacts during scrolling, which is
* the penalty of not computing the exact size.
*
* <p>NOTE: The elements in the UI (type {@code W}) must not be mutated after rendering and must
* have a constant size given a particular item and width. See {@link HistoryGroup#render(Entry)}.
* </p>
*
* @param <T> The type of item backing this history
* @param <W> The type of element or widget stored in this history
*/
public abstract class HistoryGroup<T, W extends Element<?>> extends Composite<HistoryGroup<T, W>>
    implements Interface.Task
{
    /** A label that exposes the width hint and preferred size. */
    public static class RenderableLabel extends Label
    {
        /** Creates a new label. */
        public RenderableLabel (String text) {
            super(text);
        }

        /** Calculates the size of the label, using the given width hint. */
        public Dimension calcSize (float hintX) {
            return new Dimension(preferredSize(hintX, 0));
        }
    }

    /** History group of just labels. This makes some lightweight use cases really easy. */
    public static class Labels extends HistoryGroup<String, RenderableLabel>
    {
        @Override protected void render (final Entry entry) {
            entry.element = new RenderableLabel(entry.item);
        }

        @Override protected void calcSize (Entry entry) {
            entry.size = entry.element.calcSize(_entriesWidth);
        }
    }

    /** Tests if the history is currently at the bottom. If the history is at the bottom, then
     * subsequent additions will cause automatic scrolling. By default, new groups are at the
     * bottom. Subsequent upward scrolling will clear this and scrolling to the bottom will
     * set it again. */
    public boolean atBottom () {
        return _atBottom;
    }

    /** Issues a request to scroll to the bottom of the list of history elements. */
    public void scrollToBottom () {
        _scroller.queueScroll(0, Float.MAX_VALUE);
    }

    /** Prunes the given number of entries from the beginning of the history. */
    public void pruneOld (int adjustment) {
        if (adjustment != 0) {
            _entriesGroup.removeOldEntries(_baseIndex + adjustment);
            _entries.subList(0, Math.min(adjustment, _entries.size())).clear();
            _baseIndex += adjustment;
        }
    }

    /** Adds a new item to the end of the history. If the history is at the bottom, the item
     * will be rendered immediately, otherwise the message group is invalidated so that the
     * scroll bounds will be updated. */
    public void addItem (T item) {
        // always add a new entry
        Entry entry = addEntry(item);

        // if we're not currently displayed, do nothing else
        if (!_added) {
            return;
        }

        // render immediately if at the bottom
        if (atBottom()) {
            _entriesGroup.addEntry(entry);
        } else {
            _entriesGroup.invalidate();
        }

        // pick up the changes, if any (probably not)
        schedule();

        // keep up with the scrolling
        maybeScrollToBottom();
    }

    /** Sets the vertical gap between history elements. By default, the gap is set to 1. */
    public void setVerticalGap (int vgap) {
        _vgap = vgap;
        _entriesGroup.invalidate();
    }

    @Override public void update (int delta) {
        if (!_added) {
            log.warning("Whassup, scheduled while removed?");
            cancel();
            return;
        }

        if (_widthUpdated) {
            // a bit cumbersome, but rare... remove all previously created labels
            _entriesGroup.removeAllEntries();
            _widthUpdated = false;
        }

        // maybe wait until next frame to get valid
        if (!isSet(Flag.VALID)) return;

        if (_entries.size() == 0) {
            // no entries, we're done here
            cancel();
            return;
        }

        // walk up from the bottom and render the first null one
        int bottom = findEntry(_scroller.ypos() + _viewHeight);
        int top = bottom;
        for (; top >= 0; top--) {
            Entry e = _entries.get(top);
            if (e.ypos + e.size.height() < _scroller.ypos()) {
                break;
            }
            if (e.element == null) {
                // render this one and do more next update
                // TODO: use a maximum frame time
                _entriesGroup.addEntry(e);
                return;
            }
        }

        // all entries in view are rendered, now delete ones that are far away
        float miny = _scroller.ypos() - _viewHeight;
        float maxy = _scroller.ypos() + _viewHeight * 2;

        // walk up one more view height
        for (; top >= 0; top--) {
            Entry e = _entries.get(top);
            if (e.bottom() < miny) {
                break;
            }
        }

        // walk down one more view height
        for (int size = _entries.size(); bottom < size; bottom++) {
            Entry e = _entries.get(bottom);
            if (e.ypos >= maxy) {
                break;
            }
        }

        _entriesGroup.removeEntriesNotInRange(_baseIndex + top, _baseIndex + bottom);
        cancel();
    }

    /** Creates a new history group. */
    protected HistoryGroup () {
        setLayout(AxisLayout.horizontal().stretchByDefault().offStretch());
        initChildren(_scroller = new Scroller(_entriesGroup = new EntriesGroup()).setBehavior(Scroller.Behavior.VERTICAL));
    }

    /** Sets up the {@link Entry#element} member. After this call, the element will be added to
     * the group so that style information is available.
     * <p>Note that the {@code Element.size()} value is ignored and only the entry size is
     * considered during layout, as determined in {@link #calcSize(Entry)}. */
    protected abstract void render (Entry entry);

    /** Calculates and sets the {@link Entry#size} member, according to the current
     * {@link #_entriesWidth}. Normally this must be done using a Widget that exposes its
     * {@code preferredSize} method and allows a wrap width to be set.
     * <p>This method is called after the {@link Entry#element} member is added to the group so
     * that style information can be determined.</p> */
    protected abstract void calcSize (Entry entry);

    /** Called during layout after a change to {@link #_entriesWidth} occurs. Subclasses may want
     * to update some internal layout state that relies on the width. */
    protected void didUpdateWidth (float oldWidth) {}

    /** Sets the estimated height for entries that are currently not in view. */
    protected void setEstimatedHeight (float height) {
        _estimatedSize = new Dimension(1, height);
        _entriesGroup.invalidate();
    }

    /** Scroll to the bottom if they were already at the bottom. */
    protected void maybeScrollToBottom () {
        if (atBottom()) {
            scrollToBottom();
        }
    }

    /** Convenience method to clear out all currently rendered messages and do them again. */
    protected void resetEntries () {
        _entriesGroup.removeAllEntries();
        schedule();
    }

    /** Adds a new history entry without the UI check or the scrolling to bottom. Useful for batch
     * additions from the subclass/game model of the backing storage. */
    protected Entry addEntry (T item) {
        Entry entry = new Entry(item, _baseIndex + _entries.size());
        _entries.add(entry);
        return entry;
    }

    @Override protected void wasAdded () {
        super.wasAdded();
        _added = true;

        // update the elements for visible entries later
        schedule();
    }

    @Override protected void wasRemoved () {
        _added = false;

        // free up the all currently rendered elements for garbage collection
        _entriesGroup.removeAllEntries();

        // kill off task
        cancel();

        super.wasRemoved();
    }

    @Override protected Class<?> getStyleClass () {
        return HistoryGroup.class;
    }

    protected void schedule () {
        if (_task == null && _added) {
            _task = root().iface().addTask(this);
        }
    }

    protected void cancel () {
        if (_task != null) {
            _task.remove();
            _task = null;
        }
    }

    /** Find the index of the entry at the given y position. */
    protected int findEntry (float ypos) {
        int max = _entries.size() - 1, low = 0, high = max;
        while (low <= high) {
            int mid = (low + high) >>> 1;
            float midpos = _entries.get(mid).ypos;
            if (ypos > midpos) {
                low = mid + 1;
            } else if (ypos < midpos) {
                high = mid - 1;
            } else {
                return mid; // key found
            }
        }
        return Math.min(low, max);
    }

    /**
     * An item in the history and associated layout info.
     */
    protected class Entry
    {
        /** The item. */
        public final T item;

        /** The unique index for this entry (increases by one per new entry). */
        public final int index;

        /** The last rendered size of the entry, or the estimated size if not in view. */
        public Dimension size = _estimatedSize;

        /** The y position of the top of this entry. */
        public float ypos;

        /** The rendered element for this entry, if it is currently in view. */
        public W element;

        /**
         * Creates a new entry.
         */
        public Entry (T message, int index) {
            this.item = message;
            this.index = index;
        }

        /** Do the full on render of everything, if needed. */
        public W render () {
            if (element != null) {
                return element;
            }

            HistoryGroup.this.render(this);
            return element;
        }

        public float bottom () {
            return ypos + size.height();
        }
    }

    /** Groups the rendered items in the history. */
    protected class EntriesGroup extends Group
        implements Scroller.Clippable
    {
        public EntriesGroup () {
            super(new EntriesLayout());
        }

        @Override public void setViewArea (float width, float height) {
            _viewHeight = height;
            maybeScrollToBottom();
        }

        @Override public void setPosition (float x, float y) {
            IRectangle bounds = bounds(new Rectangle());
            if (_viewHeight > bounds.height()) {
                // nail the group to the bottom of the scroll area.
                layer.setTranslation(x, _viewHeight - bounds.height());
                _atBottom = true;
            } else {
                layer.setTranslation(x, (float)Math.floor(y));
                _atBottom = -y == bounds.height() - _viewHeight;
            }
            schedule();
        }

        public void addEntry (Entry e) {
            // find the place to insert the entry
            int position = _renderedEntries.size() - 1;
            for (; position >= 0; position--) {
                Entry test = _renderedEntries.get(position);
                if (e.index > test.index) {
                    break;
                }
            }

            // add the rendered item to the ui
            position++;
            add(position, e.render());
            calcSize(e);

            // keep track of what we've rendered
            _renderedEntries.add(position, e);
        }

        public void removeEntry (Entry e) {
            int index = _renderedEntries.indexOf(e);
            if (index == -1) throw new IllegalArgumentException(
                "Removing entry that isn't in the list: " + e);
            removeUI(index);
        }

        public void removeOldEntries (int minIndex) {
            removeEntriesNotInRange(minIndex, Integer.MAX_VALUE);
        }

        public void removeEntriesNotInRange (int minIndex, int maxIndex) {
            for (int ii = 0; ii < _renderedEntries.size();) {
                int index = _renderedEntries.get(ii).index;
                if (index < minIndex || index > maxIndex) {
                    removeUI(ii);
                } else {
                    ii++;
                }
            }
        }

        public void removeAllEntries () {
            while (!_renderedEntries.isEmpty()) {
                removeUI(_renderedEntries.size() - 1);
            }
        }

        protected void removeUI (int index) {
            if (childAt(index) != _renderedEntries.get(index).element)
                throw new IllegalArgumentException("Mismatched entry and element");
            destroyAt(index);
            _renderedEntries.get(index).element = null;
            _renderedEntries.remove(index);
        }

        /** List of entries in exact correspondence with _children. */
        protected List<Entry> _renderedEntries = new ArrayList<Entry>();
    }

    /**
     * Lays out the history items.
     */
    protected class EntriesLayout extends Layout
    {
        @Override
        public Dimension computeSize (Container<?> elems, float hintX, float hintY) {
            // report a large width since we expect to always be stretched, not fixed
            Dimension size = new Dimension(4096, 0);
            if (!_entries.isEmpty()) {
                size.height += _vgap * (_entries.size() - 1);
                for (Entry e : _entries) {
                    size.height += e.size.height();
                }
            }
            return size;
        }

        @Override
        public void layout (Container<?> elems, float left, float top, float width, float height) {
            // deal with width updates
            if (_entriesWidth != width) {
                // update our width
                float old = _entriesWidth;
                _entriesWidth = width;

                didUpdateWidth(old);

                // schedule the refresh
                _widthUpdated = true;
                schedule();
            }

            // update all entries so they have a sensible ypos when needed
            for (Entry e : _entries) {
                float eheight = e.size.height();
                if (e.element != null) {
                    setBounds(e.element, left, top, e.size.width(), eheight);
                }
                e.ypos = top;
                top += eheight + _vgap;
            }
        }
    }

    /** The scrollable area, our only proper child. */
    protected Scroller _scroller;

    /** The rendered items contained in the scrollable area. */
    protected EntriesGroup _entriesGroup;

    /** The current task registration, or null if we're not updating. */
    protected Interface.TaskHandle _task;

    /** The list of history entries. */
    protected List<Entry> _entries = new ArrayList<Entry>();

    /** The current width of the rendered items group, or 0 prior to layout. */
    protected float _entriesWidth;

    /** The vertical gap between history items. */
    protected int _vgap = 1;

    /** The current height of the view area (this can be different from _scroller.size() if it
     * is ever given an inset background. */
    protected float _viewHeight;

    /** Set if we discover a change to the width during layout that needs to update UI on the
     * next update. */
    protected boolean _widthUpdated;

    /** Set if we should automatically scroll to show newly added items. */
    protected boolean _atBottom = true;

    /** Tracks isAdded(), for faster testing. */
    protected boolean _added;

    /** The unique index of the 0th entry in the history. */
    protected int _baseIndex;

    /** The size to use for new, unrendered, history entries. */
    protected Dimension _estimatedSize = new Dimension(1, 18f);
}
TOP

Related Classes of tripleplay.ui.HistoryGroup

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.