/* * Copyright 2000-2018 Vaadin Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.vaadin.client.widgets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; import java.util.TreeMap; import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; import com.google.gwt.animation.client.Animation; import com.google.gwt.animation.client.AnimationScheduler; import com.google.gwt.animation.client.AnimationScheduler.AnimationCallback; import com.google.gwt.animation.client.AnimationScheduler.AnimationHandle; import com.google.gwt.core.client.Duration; import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.core.client.JsArray; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import com.google.gwt.dom.client.DivElement; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.NativeEvent; import com.google.gwt.dom.client.Node; import com.google.gwt.dom.client.NodeList; import com.google.gwt.dom.client.Style; import com.google.gwt.dom.client.Style.Display; import com.google.gwt.dom.client.Style.Unit; import com.google.gwt.dom.client.TableCellElement; import com.google.gwt.dom.client.TableRowElement; import com.google.gwt.dom.client.TableSectionElement; import com.google.gwt.dom.client.Touch; import com.google.gwt.event.dom.client.KeyCodes; import com.google.gwt.event.shared.HandlerRegistration; import com.google.gwt.logging.client.LogConfiguration; import com.google.gwt.user.client.DOM; import com.google.gwt.user.client.Event; import com.google.gwt.user.client.Window; import com.google.gwt.user.client.ui.RequiresResize; import com.google.gwt.user.client.ui.RootPanel; import com.google.gwt.user.client.ui.UIObject; import com.google.gwt.user.client.ui.Widget; import com.vaadin.client.BrowserInfo; import com.vaadin.client.ComputedStyle; import com.vaadin.client.DeferredWorker; import com.vaadin.client.Profiler; import com.vaadin.client.WidgetUtil; import com.vaadin.client.ui.SubPartAware; import com.vaadin.client.widget.escalator.Cell; import com.vaadin.client.widget.escalator.ColumnConfiguration; import com.vaadin.client.widget.escalator.EscalatorUpdater; import com.vaadin.client.widget.escalator.FlyweightCell; import com.vaadin.client.widget.escalator.FlyweightRow; import com.vaadin.client.widget.escalator.PositionFunction; import com.vaadin.client.widget.escalator.PositionFunction.Translate3DPosition; import com.vaadin.client.widget.escalator.PositionFunction.TranslatePosition; import com.vaadin.client.widget.escalator.PositionFunction.WebkitTranslate3DPosition; import com.vaadin.client.widget.escalator.Row; import com.vaadin.client.widget.escalator.RowContainer; import com.vaadin.client.widget.escalator.RowContainer.BodyRowContainer; import com.vaadin.client.widget.escalator.RowVisibilityChangeEvent; import com.vaadin.client.widget.escalator.RowVisibilityChangeHandler; import com.vaadin.client.widget.escalator.ScrollbarBundle; import com.vaadin.client.widget.escalator.ScrollbarBundle.Direction; import com.vaadin.client.widget.escalator.ScrollbarBundle.HorizontalScrollbarBundle; import com.vaadin.client.widget.escalator.ScrollbarBundle.VerticalScrollbarBundle; import com.vaadin.client.widget.escalator.Spacer; import com.vaadin.client.widget.escalator.SpacerUpdater; import com.vaadin.client.widget.escalator.events.RowHeightChangedEvent; import com.vaadin.client.widget.escalator.events.SpacerIndexChangedEvent; import com.vaadin.client.widget.escalator.events.SpacerVisibilityChangedEvent; import com.vaadin.client.widget.grid.events.ScrollEvent; import com.vaadin.client.widget.grid.events.ScrollHandler; import com.vaadin.client.widgets.Escalator.JsniUtil.TouchHandlerBundle; import com.vaadin.shared.Range; import com.vaadin.shared.ui.grid.HeightMode; import com.vaadin.shared.ui.grid.ScrollDestination; import com.vaadin.shared.util.SharedUtil; /*- Maintenance Notes! Reading these might save your day. (note for editors: line width is 80 chars, including the one-space indentation) == Row Container Structure AbstractRowContainer |-- AbstractStaticRowContainer | |-- HeaderRowContainer | `-- FooterContainer `---- BodyRowContainerImpl AbstractRowContainer is intended to contain all common logic between RowContainers. It manages the bookkeeping of row count, makes sure that all individual cells are rendered the same way, and so on. AbstractStaticRowContainer has some special logic that is required by all RowContainers that don't scroll (hence the word "static"). HeaderRowContainer and FooterRowContainer are pretty thin special cases of a StaticRowContainer (mostly relating to positioning of the root element). BodyRowContainerImpl could also be split into an additional "AbstractScrollingRowContainer", but I felt that no more inner classes were needed. So it contains both logic required for making things scroll about, and equivalent special cases for layouting, as are found in Header/FooterRowContainers. == The Three Indices Each RowContainer can be thought to have three levels of indices for any given displayed row (but the distinction matters primarily for the BodyRowContainerImpl, because of the way it scrolls through data): - Logical index - Physical (or DOM) index - Visual index LOGICAL INDEX is the index that is linked to the data source. If you want your data source to represent a SQL database with 10 000 rows, the 7 000:th row in the SQL has a logical index of 6 999, since the index is 0-based (unless that data source does some funky logic). PHYSICAL INDEX is the index for a row that you see in a browser's DOM inspector. If your row is the second element within a tag, it has a physical index of 1 (because of 0-based indices). In Header and FooterRowContainers, you are safe to assume that the logical index is the same as the physical index. But because the BodyRowContainerImpl never displays large data sources entirely in the DOM, a physical index usually has no apparent direct relationship with its logical index. This is the sectionRowIndex in TableRowElements. RowIndex in TableRowElements displays the physical index of all row elements, headers and footers included. VISUAL INDEX is the index relating to the order that you see a row in, in the browser, as it is rendered. The topmost row is 0, the second is 1, and so on. The visual index is similar to the physical index in the sense that Header and FooterRowContainers can assume a 1:1 relationship between visual index and logical index. And again, BodyRowContainerImpl has no such relationship. The body's visual index has additionally no apparent relationship with its physical index. Because the tags are reused in the body and visually repositioned with CSS as the user scrolls, the relationship between physical index and visual index is quickly broken. You can get an element's visual index via the field BodyRowContainerImpl.visualRowOrder. Currently, the physical and visual indices are kept in sync _most of the time_ by a deferred rearrangement of rows. They become desynced when scrolling. This is to help screen readers to read the contents from the DOM in a natural order. See BodyRowContainerImpl.DeferredDomSorter for more about that. It should be noted that the entire visual range is not necessarily in view at any given time, although it should be optimised to not exceed the maximum amount of rows that can theoretically fit within the viewport when their associated spacers have zero height, except by the two rows that are required for tab navigation to work. */ /** * A workaround-class for GWT and JSNI. *

* GWT is unable to handle some method calls to Java methods in inner-classes * from within JSNI blocks. Having that inner class extend a non-inner-class (or * implement such an interface), makes it possible for JSNI to indirectly refer * to the inner class, by invoking methods and fields in the non-inner-class * API. * * @see Escalator.Scroller */ abstract class JsniWorkaround { /** * A JavaScript function that handles the scroll DOM event, and passes it on * to Java code. * * @see #createScrollListenerFunction(Escalator) * @see Escalator.Scroller#onScroll() */ protected final JavaScriptObject scrollListenerFunction; /** * A JavaScript function that handles the mousewheel DOM event, and passes * it on to Java code. * * @see #createMousewheelListenerFunction(Escalator) * @see Escalator.Scroller#onScroll() */ protected final JavaScriptObject mousewheelListenerFunction; /** * A JavaScript function that handles the touch start DOM event, and passes * it on to Java code. * * @see TouchHandlerBundle#touchStart(Escalator.JsniUtil.TouchHandlerBundle.CustomTouchEvent) */ protected JavaScriptObject touchStartFunction; /** * A JavaScript function that handles the touch move DOM event, and passes * it on to Java code. * * @see TouchHandlerBundle#touchMove(Escalator.JsniUtil.TouchHandlerBundle.CustomTouchEvent) */ protected JavaScriptObject touchMoveFunction; /** * A JavaScript function that handles the touch end and cancel DOM events, * and passes them on to Java code. * * @see TouchHandlerBundle#touchEnd(Escalator.JsniUtil.TouchHandlerBundle.CustomTouchEvent) */ protected JavaScriptObject touchEndFunction; protected TouchHandlerBundle touchHandlerBundle; protected JsniWorkaround(final Escalator escalator) { scrollListenerFunction = createScrollListenerFunction(escalator); mousewheelListenerFunction = createMousewheelListenerFunction( escalator); touchHandlerBundle = new TouchHandlerBundle(escalator); touchStartFunction = touchHandlerBundle.getTouchStartHandler(); touchMoveFunction = touchHandlerBundle.getTouchMoveHandler(); touchEndFunction = touchHandlerBundle.getTouchEndHandler(); } /** * A method that constructs the JavaScript function that will be stored into * {@link #scrollListenerFunction}. * * @param esc * a reference to the current instance of {@link Escalator} * @see Escalator.Scroller#onScroll() */ protected abstract JavaScriptObject createScrollListenerFunction( Escalator esc); /** * A method that constructs the JavaScript function that will be stored into * {@link #mousewheelListenerFunction}. * * @param esc * a reference to the current instance of {@link Escalator} * @see Escalator.Scroller#onScroll() */ protected abstract JavaScriptObject createMousewheelListenerFunction( Escalator esc); } /** * A low-level table-like widget that features a scrolling virtual viewport and * lazily generated rows. * * @since 7.4 * @author Vaadin Ltd */ public class Escalator extends Widget implements RequiresResize, DeferredWorker, SubPartAware { // todo comments legend /* * [[optimize]]: There's an opportunity to rewrite the code in such a way * that it _might_ perform better (remember to measure, implement, * re-measure) */ /* * [[mpixscroll]]: This code will require alterations that are relevant for * supporting the scrolling through more pixels than some browsers normally * would support. (i.e. when we support more than "a million" pixels in the * escalator DOM). NOTE: these bits can most often also be identified by * searching for code that call scrollElem.getScrollTop();. */ /* * [[spacer]]: Code that is important to make spacers work. */ /** * A utility class that contains utility methods that are usually called * from JSNI. *

* The methods are moved in this class to minimize the amount of JSNI code * as much as feasible. */ static class JsniUtil { public static class TouchHandlerBundle { public static final String POINTER_EVENT_TYPE_TOUCH = "touch"; public static final int SIGNIFICANT_MOVE_THRESHOLD = 3; /** * A JavaScriptObject overlay for the * JavaScript * TouchEvent object. *

* This needs to be used in the touch event handlers, since GWT's * {@link com.google.gwt.event.dom.client.TouchEvent TouchEvent} * can't be cast from the JSNI call, and the * {@link com.google.gwt.dom.client.NativeEvent NativeEvent} isn't * properly populated with the correct values. */ private static final class CustomTouchEvent extends JavaScriptObject { protected CustomTouchEvent() { } public native NativeEvent getNativeEvent() /*-{ return this; }-*/; public native int getPageX() /*-{ return this.targetTouches[0].pageX; }-*/; public native int getPageY() /*-{ return this.targetTouches[0].pageY; }-*/; public native String getPointerType() /*-{ return this.pointerType; }-*/; } private final Escalator escalator; public TouchHandlerBundle(final Escalator escalator) { this.escalator = escalator; } public native JavaScriptObject getTouchStartHandler() /*-{ // we need to store "this", since it won't be preserved on call. var self = this; return $entry(function (e) { self.@com.vaadin.client.widgets.Escalator.JsniUtil.TouchHandlerBundle::touchStart(*)(e); }); }-*/; public native JavaScriptObject getTouchMoveHandler() /*-{ // we need to store "this", since it won't be preserved on call. var self = this; return $entry(function (e) { self.@com.vaadin.client.widgets.Escalator.JsniUtil.TouchHandlerBundle::touchMove(*)(e); }); }-*/; public native JavaScriptObject getTouchEndHandler() /*-{ // we need to store "this", since it won't be preserved on call. var self = this; return $entry(function (e) { self.@com.vaadin.client.widgets.Escalator.JsniUtil.TouchHandlerBundle::touchEnd(*)(e); }); }-*/; // Duration of the inertial scrolling simulation. Devices with // larger screens take longer durations. private static final int DURATION = Window.getClientHeight(); // multiply scroll velocity with repeated touching private int acceleration = 1; private boolean touching = false; // Two movement objects for storing status and processing touches private Movement yMov, xMov; // true if moved significantly since touch start private boolean movedSignificantly = false; private double touchStartTime; final double MIN_VEL = 0.6, MAX_VEL = 4, F_VEL = 1500, F_ACC = 0.7, F_AXIS = 1; // The object to deal with one direction scrolling private class Movement { final List speeds = new ArrayList<>(); final ScrollbarBundle scroll; double position, offset, velocity, prevPos, prevTime, delta; boolean run, vertical; public Movement(boolean vertical) { this.vertical = vertical; scroll = vertical ? escalator.verticalScrollbar : escalator.horizontalScrollbar; } public void startTouch(CustomTouchEvent event) { speeds.clear(); prevPos = pagePosition(event); prevTime = Duration.currentTimeMillis(); } public void moveTouch(CustomTouchEvent event) { double pagePosition = pagePosition(event); if (pagePosition > -1) { delta = prevPos - pagePosition; double now = Duration.currentTimeMillis(); double ellapsed = now - prevTime; velocity = delta / ellapsed; // if last speed was so low, reset speeds and start // storing again if (!speeds.isEmpty() && !validSpeed(speeds.get(0))) { speeds.clear(); run = true; } speeds.add(0, velocity); prevTime = now; prevPos = pagePosition; } } public void endTouch(CustomTouchEvent event) { // Compute average speed velocity = 0; for (double s : speeds) { velocity += s / speeds.size(); } position = scroll.getScrollPos(); // Compute offset, and adjust it with an easing curve so as // movement is smoother. offset = F_VEL * velocity * acceleration * easingInOutCos(velocity, MAX_VEL); // Enable or disable inertia movement in this axis run = validSpeed(velocity); if (run) { event.getNativeEvent().preventDefault(); } } void validate(Movement other) { if (!run || other.velocity > 0 && Math.abs(velocity / other.velocity) < F_AXIS) { delta = offset = 0; run = false; } } void stepAnimation(double progress) { scroll.setScrollPos(position + offset * progress); } int pagePosition(CustomTouchEvent event) { // Use native event's screen x and y for IE11 and Edge // since there is no touches for these browsers (#18737) if (isCurrentBrowserIE11OrEdge()) { return vertical ? event.getNativeEvent().getClientY() + Window.getScrollTop() : event.getNativeEvent().getClientX() + Window.getScrollLeft(); } JsArray a = event.getNativeEvent().getTouches(); return vertical ? a.get(0).getPageY() : a.get(0).getPageX(); } boolean validSpeed(double speed) { return Math.abs(speed) > MIN_VEL; } } // Using GWT animations which take care of native animation frames. private Animation animation = new Animation() { @Override public void onUpdate(double progress) { xMov.stepAnimation(progress); yMov.stepAnimation(progress); } @Override public double interpolate(double progress) { return easingOutCirc(progress); }; @Override public void onComplete() { touching = false; escalator.body.domSorter.reschedule(); }; @Override public void run(int duration) { if (xMov.run || yMov.run) { super.run(duration); } else { onComplete(); } }; }; public void touchStart(final CustomTouchEvent event) { if (allowTouch(event)) { if (yMov == null) { yMov = new Movement(true); xMov = new Movement(false); } if (animation.isRunning()) { acceleration += F_ACC; event.getNativeEvent().preventDefault(); animation.cancel(); } else { acceleration = 1; } xMov.startTouch(event); yMov.startTouch(event); touchStartTime = Duration.currentTimeMillis(); touching = true; movedSignificantly = false; } else { touching = false; animation.cancel(); acceleration = 1; } } public void touchMove(final CustomTouchEvent event) { if (touching) { if (!movedSignificantly) { double distanceSquared = Math.abs(xMov.delta) * Math.abs(xMov.delta) + Math.abs(yMov.delta) * Math.abs(yMov.delta); movedSignificantly = distanceSquared > SIGNIFICANT_MOVE_THRESHOLD * SIGNIFICANT_MOVE_THRESHOLD; } // allow handling long press differently, without triggering // scrolling if (escalator.getDelayToCancelTouchScroll() >= 0 && !movedSignificantly && Duration.currentTimeMillis() - touchStartTime > escalator .getDelayToCancelTouchScroll()) { // cancel touch handling, don't prevent event touching = false; animation.cancel(); acceleration = 1; return; } xMov.moveTouch(event); yMov.moveTouch(event); xMov.validate(yMov); yMov.validate(xMov); moveScrollFromEvent(escalator, xMov.delta, yMov.delta, event.getNativeEvent()); } } public void touchEnd(final CustomTouchEvent event) { if (touching) { xMov.endTouch(event); yMov.endTouch(event); xMov.validate(yMov); yMov.validate(xMov); // Adjust duration so as longer movements take more duration boolean vert = !xMov.run || yMov.run && Math.abs(yMov.offset) > Math.abs(xMov.offset); double delta = Math.abs((vert ? yMov : xMov).offset); animation.run((int) (3 * DURATION * easingOutExp(delta))); } } // Allow touchStart for IE11 and Edge even though there is no touch // (#18737), // otherwise allow touch only if there is a single touch in the // event private boolean allowTouch( final TouchHandlerBundle.CustomTouchEvent event) { if (isCurrentBrowserIE11OrEdge()) { return (POINTER_EVENT_TYPE_TOUCH .equals(event.getPointerType())); } else { return (event.getNativeEvent().getTouches().length() == 1); } } private double easingInOutCos(double val, double max) { return 0.5 - 0.5 * Math.cos(Math.PI * Math.signum(val) * Math.min(Math.abs(val), max) / max); } private double easingOutExp(double delta) { return (1 - Math.pow(2, -delta / 1000)); } private double easingOutCirc(double progress) { return Math.sqrt(1 - (progress - 1) * (progress - 1)); } } public static void moveScrollFromEvent(final Escalator escalator, final double deltaX, final double deltaY, final NativeEvent event) { boolean scrollPosXChanged = false; boolean scrollPosYChanged = false; if (!Double.isNaN(deltaX)) { double oldScrollPosX = escalator.horizontalScrollbar .getScrollPos(); escalator.horizontalScrollbar.setScrollPosByDelta(deltaX); if (oldScrollPosX != escalator.horizontalScrollbar .getScrollPos()) { scrollPosXChanged = true; } } if (!Double.isNaN(deltaY)) { double oldScrollPosY = escalator.verticalScrollbar .getScrollPos(); escalator.verticalScrollbar.setScrollPosByDelta(deltaY); if (oldScrollPosY != escalator.verticalScrollbar .getScrollPos()) { scrollPosYChanged = true; } } /* * Only prevent if internal scrolling happened. If there's no more * room to scroll internally, allow the event to pass further. */ final boolean warrantedYScroll = deltaY != 0 && scrollPosYChanged && escalator.verticalScrollbar.showsScrollHandle(); final boolean warrantedXScroll = deltaX != 0 && scrollPosXChanged && escalator.horizontalScrollbar.showsScrollHandle(); if (warrantedYScroll || warrantedXScroll) { event.preventDefault(); } } } /** * ScrollDestination case-specific handling logic. */ private static double getScrollPos(final ScrollDestination destination, final double targetStartPx, final double targetEndPx, final double viewportStartPx, final double viewportEndPx, final double padding) { final double viewportLength = viewportEndPx - viewportStartPx; switch (destination) { /* * Scroll as little as possible to show the target element. If the * element fits into view, this works as START or END depending on the * current scroll position. If the element does not fit into view, this * works as START. */ case ANY: { final double startScrollPos = targetStartPx - padding; final double endScrollPos = targetEndPx + padding - viewportLength; if (startScrollPos < viewportStartPx) { return startScrollPos; } else if (targetEndPx + padding > viewportEndPx) { return endScrollPos; } else { // NOOP, it's already visible return viewportStartPx; } } /* * Scrolls so that the element is shown at the end of the viewport. The * viewport will, however, not scroll before its first element. */ case END: { return targetEndPx + padding - viewportLength; } /* * Scrolls so that the element is shown in the middle of the viewport. * The viewport will, however, not scroll beyond its contents, given * more elements than what the viewport is able to show at once. Under * no circumstances will the viewport scroll before its first element. */ case MIDDLE: { final double targetMiddle = targetStartPx + (targetEndPx - targetStartPx) / 2; return targetMiddle - viewportLength / 2; } /* * Scrolls so that the element is shown at the start of the viewport. * The viewport will, however, not scroll beyond its contents. */ case START: { return targetStartPx - padding; } /* * Throw an error if we're here. This can only mean that * ScrollDestination has been carelessly amended.. */ default: { throw new IllegalArgumentException( "Internal: ScrollDestination has been modified, " + "but Escalator.getScrollPos has not been updated " + "to match new values."); } } } /** An inner class that handles all logic related to scrolling. */ private class Scroller extends JsniWorkaround { private double lastScrollTop = 0; private double lastScrollLeft = 0; public Scroller() { super(Escalator.this); } @Override protected native JavaScriptObject createScrollListenerFunction( Escalator esc) /*-{ var vScroll = esc.@com.vaadin.client.widgets.Escalator::verticalScrollbar; var vScrollElem = vScroll.@com.vaadin.client.widget.escalator.ScrollbarBundle::getElement()(); var hScroll = esc.@com.vaadin.client.widgets.Escalator::horizontalScrollbar; var hScrollElem = hScroll.@com.vaadin.client.widget.escalator.ScrollbarBundle::getElement()(); return $entry(function(e) { var target = e.target; // in case the scroll event was native (i.e. scrollbars were dragged, or // the scrollTop/Left was manually modified), the bundles have old cache // values. We need to make sure that the caches are kept up to date. if (target === vScrollElem) { vScroll.@com.vaadin.client.widget.escalator.ScrollbarBundle::updateScrollPosFromDom()(); } else if (target === hScrollElem) { hScroll.@com.vaadin.client.widget.escalator.ScrollbarBundle::updateScrollPosFromDom()(); } else { $wnd.console.error("unexpected scroll target: "+target); } }); }-*/; @Override protected native JavaScriptObject createMousewheelListenerFunction( Escalator esc) /*-{ return $entry(function(e) { var deltaX = e.deltaX ? e.deltaX : -0.5*e.wheelDeltaX; var deltaY = e.deltaY ? e.deltaY : -0.5*e.wheelDeltaY; // Delta mode 0 is in pixels; we don't need to do anything... // A delta mode of 1 means we're scrolling by lines instead of pixels // We need to scale the number of lines by the default line height if (e.deltaMode === 1) { var brc = esc.@com.vaadin.client.widgets.Escalator::body; deltaY *= brc.@com.vaadin.client.widgets.Escalator.AbstractRowContainer::getDefaultRowHeight()(); } // Other delta modes aren't supported if ((e.deltaMode !== undefined) && (e.deltaMode >= 2 || e.deltaMode < 0)) { var msg = "Unsupported wheel delta mode \"" + e.deltaMode + "\""; // Print warning message esc.@com.vaadin.client.widgets.Escalator::logWarning(*)(msg); } // IE8 has only delta y if (isNaN(deltaY)) { deltaY = -0.5*e.wheelDelta; } @com.vaadin.client.widgets.Escalator.JsniUtil::moveScrollFromEvent(*)(esc, deltaX, deltaY, e); }); }-*/; /** * Recalculates the virtual viewport represented by the scrollbars, so * that the sizes of the scroll handles appear correct in the browser */ public void recalculateScrollbarsForVirtualViewport() { double scrollContentHeight = body.calculateTotalRowHeight() + body.spacerContainer.getSpacerHeightsSum(); double scrollContentWidth = columnConfiguration.calculateRowWidth(); double tableWrapperHeight = heightOfEscalator; double tableWrapperWidth = widthOfEscalator; boolean verticalScrollNeeded = scrollContentHeight > tableWrapperHeight + WidgetUtil.PIXEL_EPSILON - header.getHeightOfSection() - footer.getHeightOfSection(); boolean horizontalScrollNeeded = scrollContentWidth > tableWrapperWidth + WidgetUtil.PIXEL_EPSILON; // One dimension got scrollbars, but not the other. Recheck time! if (verticalScrollNeeded != horizontalScrollNeeded) { if (!verticalScrollNeeded && horizontalScrollNeeded) { verticalScrollNeeded = scrollContentHeight > tableWrapperHeight + WidgetUtil.PIXEL_EPSILON - header.getHeightOfSection() - footer.getHeightOfSection() - horizontalScrollbar.getScrollbarThickness(); } else { horizontalScrollNeeded = scrollContentWidth > tableWrapperWidth + WidgetUtil.PIXEL_EPSILON - verticalScrollbar.getScrollbarThickness(); } } // let's fix the table wrapper size, since it's now stable. if (verticalScrollNeeded) { tableWrapperWidth -= verticalScrollbar.getScrollbarThickness(); tableWrapperWidth = Math.max(0, tableWrapperWidth); } if (horizontalScrollNeeded) { tableWrapperHeight -= horizontalScrollbar .getScrollbarThickness(); tableWrapperHeight = Math.max(0, tableWrapperHeight); } tableWrapper.getStyle().setHeight(tableWrapperHeight, Unit.PX); tableWrapper.getStyle().setWidth(tableWrapperWidth, Unit.PX); double footerHeight = footer.getHeightOfSection(); double headerHeight = header.getHeightOfSection(); double vScrollbarHeight = Math.max(0, tableWrapperHeight - footerHeight - headerHeight); verticalScrollbar.setOffsetSize(vScrollbarHeight); verticalScrollbar.setScrollSize(scrollContentHeight); /* * If decreasing the amount of frozen columns, and scrolled to the * right, the scroll position might reset. So we need to remember * the scroll position, and re-apply it once the scrollbar size has * been adjusted. */ double prevScrollPos = horizontalScrollbar.getScrollPos(); double unfrozenPixels = columnConfiguration .getCalculatedColumnsWidth(Range.between( columnConfiguration.getFrozenColumnCount(), columnConfiguration.getColumnCount())); double frozenPixels = scrollContentWidth - unfrozenPixels; double hScrollOffsetWidth = tableWrapperWidth - frozenPixels; horizontalScrollbar.setOffsetSize(hScrollOffsetWidth); horizontalScrollbar.setScrollSize(unfrozenPixels); horizontalScrollbar.getElement().getStyle().setLeft(frozenPixels, Unit.PX); horizontalScrollbar.setScrollPos(prevScrollPos); /* * only show the scrollbar wrapper if the scrollbar itself is * visible. */ if (horizontalScrollbar.showsScrollHandle()) { horizontalScrollbarDeco.getStyle().clearDisplay(); } else { horizontalScrollbarDeco.getStyle().setDisplay(Display.NONE); } /* * only show corner background divs if the vertical scrollbar is * visible. */ Style hCornerStyle = headerDeco.getStyle(); Style fCornerStyle = footerDeco.getStyle(); if (verticalScrollbar.showsScrollHandle()) { hCornerStyle.clearDisplay(); fCornerStyle.clearDisplay(); if (horizontalScrollbar.showsScrollHandle()) { double offset = horizontalScrollbar.getScrollbarThickness(); fCornerStyle.setBottom(offset, Unit.PX); } else { fCornerStyle.clearBottom(); } } else { hCornerStyle.setDisplay(Display.NONE); fCornerStyle.setDisplay(Display.NONE); } } /** * Logical scrolling event handler for the entire widget. */ public void onScroll() { final double scrollTop = verticalScrollbar.getScrollPos(); final double scrollLeft = horizontalScrollbar.getScrollPos(); if (lastScrollLeft != scrollLeft) { for (int i = 0; i < columnConfiguration.frozenColumns; i++) { header.updateFreezePosition(i, scrollLeft); body.updateFreezePosition(i, scrollLeft); footer.updateFreezePosition(i, scrollLeft); } position.set(headElem, -scrollLeft, 0); position.set(footElem, -scrollLeft, 0); lastScrollLeft = scrollLeft; } body.setBodyScrollPosition(scrollLeft, scrollTop); lastScrollTop = scrollTop; body.updateEscalatorRowsOnScroll(); body.spacerContainer.updateSpacerDecosVisibility(); /* * TODO [[optimize]]: Might avoid a reflow by first calculating new * scrolltop and scrolleft, then doing the escalator magic based on * those numbers and only updating the positions after that. */ } public native void attachScrollListener(Element element) /* * Attaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ if (element.addEventListener) { element.addEventListener("scroll", this.@com.vaadin.client.widgets.JsniWorkaround::scrollListenerFunction); } else { element.attachEvent("onscroll", this.@com.vaadin.client.widgets.JsniWorkaround::scrollListenerFunction); } }-*/; public native void detachScrollListener(Element element) /* * Detaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ if (element.addEventListener) { element.removeEventListener("scroll", this.@com.vaadin.client.widgets.JsniWorkaround::scrollListenerFunction); } else { element.detachEvent("onscroll", this.@com.vaadin.client.widgets.JsniWorkaround::scrollListenerFunction); } }-*/; public native void attachMousewheelListener(Element element) /* * Attaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ // firefox likes "wheel", while others use "mousewheel" var eventName = 'onmousewheel' in element ? 'mousewheel' : 'wheel'; element.addEventListener(eventName, this.@com.vaadin.client.widgets.JsniWorkaround::mousewheelListenerFunction); }-*/; public native void detachMousewheelListener(Element element) /* * Detaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ // firefox likes "wheel", while others use "mousewheel" var eventName = element.onwheel===undefined?"mousewheel":"wheel"; element.removeEventListener(eventName, this.@com.vaadin.client.widgets.JsniWorkaround::mousewheelListenerFunction); }-*/; public native void attachTouchListeners(Element element) /* * Detaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ element.addEventListener("touchstart", this.@com.vaadin.client.widgets.JsniWorkaround::touchStartFunction); element.addEventListener("touchmove", this.@com.vaadin.client.widgets.JsniWorkaround::touchMoveFunction); element.addEventListener("touchend", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); element.addEventListener("touchcancel", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); }-*/; public native void detachTouchListeners(Element element) /* * Detaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ element.removeEventListener("touchstart", this.@com.vaadin.client.widgets.JsniWorkaround::touchStartFunction); element.removeEventListener("touchmove", this.@com.vaadin.client.widgets.JsniWorkaround::touchMoveFunction); element.removeEventListener("touchend", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); element.removeEventListener("touchcancel", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); }-*/; /** * Using pointerdown, pointermove, pointerup, and pointercancel for IE11 * and Edge instead of touch* listeners (#18737) * * @param element */ public native void attachPointerEventListeners(Element element) /* * Attaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ element.addEventListener("pointerdown", this.@com.vaadin.client.widgets.JsniWorkaround::touchStartFunction); element.addEventListener("pointermove", this.@com.vaadin.client.widgets.JsniWorkaround::touchMoveFunction); element.addEventListener("pointerup", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); element.addEventListener("pointercancel", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); }-*/; /** * Using pointerdown, pointermove, pointerup, and pointercancel for IE11 * and Edge instead of touch* listeners (#18737) * * @param element */ public native void detachPointerEventListeners(Element element) /* * Detaching events with JSNI instead of the GWT event mechanism because * GWT didn't provide enough details in events, or triggering the event * handlers with GWT bindings was unsuccessful. Maybe, with more time * and skill, it could be done with better success. JavaScript overlay * types might work. This might also get rid of the JsniWorkaround * class. */ /*-{ element.removeEventListener("pointerdown", this.@com.vaadin.client.widgets.JsniWorkaround::touchStartFunction); element.removeEventListener("pointermove", this.@com.vaadin.client.widgets.JsniWorkaround::touchMoveFunction); element.removeEventListener("pointerup", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); element.removeEventListener("pointercancel", this.@com.vaadin.client.widgets.JsniWorkaround::touchEndFunction); }-*/; public void scrollToColumn(final int columnIndex, final ScrollDestination destination, final int padding) { assert columnIndex >= columnConfiguration.frozenColumns : "Can't scroll to a frozen column"; /* * To cope with frozen columns, we just pretend those columns are * not there at all when calculating the position of the target * column and the boundaries of the viewport. The resulting * scrollLeft will be correct without compensation since the DOM * structure effectively means that scrollLeft also ignores the * frozen columns. */ final double frozenPixels = columnConfiguration .getCalculatedColumnsWidth(Range.withLength(0, columnConfiguration.frozenColumns)); final double targetStartPx = columnConfiguration .getCalculatedColumnsWidth(Range.withLength(0, columnIndex)) - frozenPixels; final double targetEndPx = targetStartPx + columnConfiguration.getColumnWidthActual(columnIndex); final double viewportStartPx = getScrollLeft(); double viewportEndPx = viewportStartPx + getBoundingWidth(getElement()) - frozenPixels; if (verticalScrollbar.showsScrollHandle()) { viewportEndPx -= WidgetUtil.getNativeScrollbarSize(); } final double scrollLeft = getScrollPos(destination, targetStartPx, targetEndPx, viewportStartPx, viewportEndPx, padding); /* * note that it doesn't matter if the scroll would go beyond the * content, since the browser will adjust for that, and everything * fall into line accordingly. */ setScrollLeft(scrollLeft); } public void scrollToRow(final int rowIndex, final ScrollDestination destination, final double padding) { body.scrollToRowSpacerOrBoth(rowIndex, destination, padding, ScrollType.ROW); } } /** * Helper class that helps to implement the WAI-ARIA functionality for the * Grid and TreeGrid component. *

* The following WAI-ARIA attributes are added through this class: * *

* * @since 8.2 */ public class AriaGridHelper { /** * This field contains the total number of rows from the grid including * rows from thead, tbody and tfoot. * * @since 8.2 */ private int allRows; /** * Adds the given numberOfRows to allRows and calls * {@link #updateAriaRowCount()}. * * @param numberOfRows * number of rows that were added to the grid * * @since 8.2 */ public void addRows(int numberOfRows) { allRows += numberOfRows; updateAriaRowCount(); } /** * Removes the given numberOfRows from allRows and calls * {@link #updateAriaRowCount()}. * * @param numberOfRows * number of rows that were removed from the grid * * @since 8.2 */ public void removeRows(int numberOfRows) { allRows -= numberOfRows; updateAriaRowCount(); } /** * Sets the aria-rowcount attribute with the current value of * {@link AriaGridHelper#allRows} if the grid is attached and * {@link AriaGridHelper#allRows} > 0. * * @since 8.2 */ public void updateAriaRowCount() { if (!isAttached() || 0 > allRows) { return; } getTable().setAttribute("aria-rowcount", String.valueOf(allRows)); } /** * Sets the {@code role} attribute to the given element. * * @param element * element that should get the role attribute * @param role * role to be added * * @since 8.2 */ public void updateRole(final Element element, AriaGridRole role) { element.setAttribute("role", role.getName()); } } /** * Holds the currently used aria roles within the grid for rows and cells. * * @since 8.2 */ public enum AriaGridRole { ROW("row"), ROWHEADER("rowheader"), ROWGROUP("rowgroup"), GRIDCELL( "gridcell"), COLUMNHEADER("columnheader"); private final String name; AriaGridRole(String name) { this.name = name; } /** * Return the name of the {@link AriaGridRole}. * * @return String name to be used as role attribute */ public String getName() { return name; } } public abstract class AbstractRowContainer implements RowContainer { private EscalatorUpdater updater = EscalatorUpdater.NULL; private int rows; /** * The table section element ({@code }, {@code } or * {@code }) the rows (i.e. <tr> tags) are * contained in. */ protected final TableSectionElement root; /** * The primary style name of the escalator. Most commonly provided by * Escalator as "v-escalator". */ private String primaryStyleName = null; private boolean defaultRowHeightShouldBeAutodetected = true; private double defaultRowHeight = INITIAL_DEFAULT_ROW_HEIGHT; private boolean initialColumnSizesCalculated = false; private boolean autodetectingRowHeightLater = false; public AbstractRowContainer( final TableSectionElement rowContainerElement) { root = rowContainerElement; ariaGridHelper.updateRole(root, AriaGridRole.ROWGROUP); } @Override public TableSectionElement getElement() { return root; } /** * Gets the tag name of an element to represent a cell in a row. *

* Usually {@code "th"} or {@code "td"}. *

* Note: To actually create such an element, use * {@link #createCellElement(double)} instead. * * @return the tag name for the element to represent cells as * @see #createCellElement(double) */ protected abstract String getCellElementTagName(); /** * Gets the role attribute of an element to represent a cell in a row. *

* Usually {@link AriaGridRole#GRIDCELL} except for a cell in the * header. * * @return the role attribute for the element to represent cells * * @since 8.2 */ protected AriaGridRole getCellElementRole() { return AriaGridRole.GRIDCELL; } /** * Gets the role attribute of an element to represent a row in a grid. *

* Usually {@link AriaGridRole#ROW} except for a row in the header. * * @return the role attribute for the element to represent rows * * @since 8.2 */ protected AriaGridRole getRowElementRole() { return AriaGridRole.ROW; } @Override public EscalatorUpdater getEscalatorUpdater() { return updater; } /** * {@inheritDoc} *

* Implementation detail: This method does no DOM modifications * (i.e. is very cheap to call) if there is no data for rows or columns * when this method is called. * * @see #hasColumnAndRowData() */ @Override public void setEscalatorUpdater( final EscalatorUpdater escalatorUpdater) { if (escalatorUpdater == null) { throw new IllegalArgumentException( "escalator updater cannot be null"); } updater = escalatorUpdater; if (hasColumnAndRowData() && getRowCount() > 0) { refreshRows(0, getRowCount()); } } /** * {@inheritDoc} *

* Implementation detail: This method does no DOM modifications * (i.e. is very cheap to call) if there are no rows in the DOM when * this method is called. * * @see #hasSomethingInDom() */ @Override public void removeRows(final int index, final int numberOfRows) { assertArgumentsAreValidAndWithinRange(index, numberOfRows); rows -= numberOfRows; ariaGridHelper.removeRows(numberOfRows); if (!isAttached()) { return; } if (hasSomethingInDom()) { paintRemoveRows(index, numberOfRows); } } /** * Removes those row elements from the DOM that correspond to the given * range of logical indices. This may be fewer than {@code numberOfRows} * , even zero, if not all the removed rows are actually visible. *

* The implementation must call * {@link #paintRemoveRow(TableRowElement, int)} for each row that is * removed from the DOM. * * @param index * the logical index of the first removed row * @param numberOfRows * number of logical rows to remove */ protected abstract void paintRemoveRows(final int index, final int numberOfRows); /** * Removes a row element from the DOM, invoking * {@link #getEscalatorUpdater()} * {@link EscalatorUpdater#preDetach(Row, Iterable) preDetach} and * {@link EscalatorUpdater#postDetach(Row, Iterable) postDetach} before * and after removing the row, respectively. *

* This method must be called for each removed DOM row by any * {@link #paintRemoveRows(int, int)} implementation. * * @param tr * the row element to remove. * @param logicalRowIndex * logical index of the row that is to be removed */ protected void paintRemoveRow(final TableRowElement tr, final int logicalRowIndex) { flyweightRow.setup(tr, logicalRowIndex, columnConfiguration.getCalculatedColumnWidths()); getEscalatorUpdater().preDetach(flyweightRow, flyweightRow.getCells()); tr.removeFromParent(); getEscalatorUpdater().postDetach(flyweightRow, flyweightRow.getCells()); /* * the "assert" guarantees that this code is run only during * development/debugging. */ assert flyweightRow.teardown(); } protected void assertArgumentsAreValidAndWithinRange(final int index, final int numberOfRows) throws IllegalArgumentException, IndexOutOfBoundsException { if (numberOfRows < 1) { throw new IllegalArgumentException( "Number of rows must be 1 or greater (was " + numberOfRows + ")"); } if (index < 0 || index + numberOfRows > getRowCount()) { throw new IndexOutOfBoundsException("The given " + "row range (" + index + ".." + (index + numberOfRows) + ") was outside of the current number of rows (" + getRowCount() + ")"); } } @Override public int getRowCount() { return rows; } /** * This method calculates the current row count directly from the DOM. *

* While Escalator is stable, this value should equal to * {@link #getRowCount()}, but while row counts are being updated, these * two values might differ for a short while. *

* Any extra content, such as spacers for the body, should not be * included in this count. * * @since 7.5.0 * * @return the actual DOM count of rows */ public abstract int getDomRowCount(); /** * {@inheritDoc} *

* Implementation detail: This method does no DOM modifications * (i.e. is very cheap to call) if there is no data for columns when * this method is called. * * @see #hasColumnAndRowData() */ @Override public void insertRows(final int index, final int numberOfRows) { if (index < 0 || index > getRowCount()) { throw new IndexOutOfBoundsException("The given index (" + index + ") was outside of the current number of rows (0.." + getRowCount() + ")"); } if (numberOfRows < 1) { throw new IllegalArgumentException( "Number of rows must be 1 or greater (was " + numberOfRows + ")"); } rows += numberOfRows; ariaGridHelper.addRows(numberOfRows); /* * only add items in the DOM if the widget itself is attached to the * DOM. We can't calculate sizes otherwise. */ if (isAttached()) { paintInsertRows(index, numberOfRows); /* * We are inserting the first rows in this container. We * potentially need to set the widths for the cells for the * first time. */ if (rows == numberOfRows) { Scheduler.get().scheduleFinally(() -> { if (initialColumnSizesCalculated) { return; } initialColumnSizesCalculated = true; Map colWidths = new HashMap<>(); for (int i = 0; i < getColumnConfiguration() .getColumnCount(); i++) { Double width = Double.valueOf( getColumnConfiguration().getColumnWidth(i)); Integer col = Integer.valueOf(i); colWidths.put(col, width); } getColumnConfiguration().setColumnWidths(colWidths); }); } } } /** * Actually add rows into the DOM, now that everything can be * calculated. * * @param visualIndex * the DOM index to add rows into * @param numberOfRows * the number of rows to insert */ protected abstract void paintInsertRows(final int visualIndex, final int numberOfRows); protected List paintInsertStaticRows( final int visualIndex, final int numberOfRows) { assert isAttached() : "Can't paint rows if Escalator is not attached"; final List addedRows = new ArrayList<>(); if (numberOfRows < 1) { return addedRows; } Node referenceRow; if (root.getChildCount() != 0 && visualIndex != 0) { // get the row node we're inserting stuff after referenceRow = root.getChild(visualIndex - 1); } else { // index is 0, so just prepend. referenceRow = null; } for (int row = visualIndex; row < visualIndex + numberOfRows; row++) { final TableRowElement tr = TableRowElement.as(DOM.createTR()); addedRows.add(tr); tr.addClassName(getStylePrimaryName() + "-row"); ariaGridHelper.updateRole(tr, getRowElementRole()); for (int col = 0; col < columnConfiguration .getColumnCount(); col++) { final double colWidth = columnConfiguration .getColumnWidthActual(col); final TableCellElement cellElem = createCellElement( colWidth); tr.appendChild(cellElem); // Set stylename and position if new cell is frozen if (col < columnConfiguration.frozenColumns) { cellElem.addClassName("frozen"); position.set(cellElem, scroller.lastScrollLeft, 0); } if (columnConfiguration.frozenColumns > 0 && col == columnConfiguration.frozenColumns - 1) { cellElem.addClassName("last-frozen"); } } referenceRow = paintInsertRow(referenceRow, tr, row); } reapplyRowWidths(); recalculateSectionHeight(); return addedRows; } /** * Inserts a single row into the DOM, invoking * {@link #getEscalatorUpdater()} * {@link EscalatorUpdater#preAttach(Row, Iterable) preAttach} and * {@link EscalatorUpdater#postAttach(Row, Iterable) postAttach} before * and after inserting the row, respectively. The row should have its * cells already inserted. * * @param referenceRow * the row after which to insert or null if insert as first * @param tr * the row to be inserted * @param logicalRowIndex * the logical index of the inserted row * @return the inserted row to be used as the new reference */ protected Node paintInsertRow(Node referenceRow, final TableRowElement tr, int logicalRowIndex) { flyweightRow.setup(tr, logicalRowIndex, columnConfiguration.getCalculatedColumnWidths()); getEscalatorUpdater().preAttach(flyweightRow, flyweightRow.getCells()); referenceRow = insertAfterReferenceAndUpdateIt(root, tr, referenceRow); getEscalatorUpdater().postAttach(flyweightRow, flyweightRow.getCells()); updater.update(flyweightRow, flyweightRow.getCells()); /* * the "assert" guarantees that this code is run only during * development/debugging. */ assert flyweightRow.teardown(); return referenceRow; } private Node insertAfterReferenceAndUpdateIt(final Element parent, final Element elem, final Node referenceNode) { if (referenceNode != null) { parent.insertAfter(elem, referenceNode); } else { /* * referencenode being null means we have offset 0, i.e. make it * the first row */ /* * TODO [[optimize]]: Is insertFirst or append faster for an * empty root? */ parent.insertFirst(elem); } return elem; } protected abstract void recalculateSectionHeight(); /** * Returns the height of all rows in the row container. */ protected double calculateTotalRowHeight() { return getDefaultRowHeight() * getRowCount(); } /** * {@inheritDoc} *

* Implementation detail: This method does no DOM modifications * (i.e. is very cheap to call) if there is no data for columns when * this method is called. * * @see #hasColumnAndRowData() */ @Override // overridden because of JavaDoc public void refreshRows(final int index, final int numberOfRows) { Range rowRange = Range.withLength(index, numberOfRows); Range colRange = Range.withLength(0, getColumnConfiguration().getColumnCount()); refreshCells(rowRange, colRange); } protected abstract void refreshCells(Range logicalRowRange, Range colRange); void refreshRow(TableRowElement tr, int logicalRowIndex) { refreshRow(tr, logicalRowIndex, Range.withLength(0, getColumnConfiguration().getColumnCount())); } void refreshRow(final TableRowElement tr, final int logicalRowIndex, Range colRange) { flyweightRow.setup(tr, logicalRowIndex, columnConfiguration.getCalculatedColumnWidths()); Iterable cellsToUpdate = flyweightRow .getCells(colRange.getStart(), colRange.length()); updater.update(flyweightRow, cellsToUpdate); /* * the "assert" guarantees that this code is run only during * development/debugging. */ assert flyweightRow.teardown(); } /** * Create and setup an empty cell element. * * @param width * the width of the cell, in pixels * * @return a set-up empty cell element */ public TableCellElement createCellElement(final double width) { final TableCellElement cellElem = TableCellElement .as(DOM.createElement(getCellElementTagName())); final double height = getDefaultRowHeight(); assert height >= 0 : "defaultRowHeight was negative. There's a setter leak somewhere."; cellElem.getStyle().setHeight(height, Unit.PX); if (width >= 0) { cellElem.getStyle().setWidth(width, Unit.PX); } cellElem.addClassName(getStylePrimaryName() + "-cell"); ariaGridHelper.updateRole(cellElem, getCellElementRole()); return cellElem; } @Override public TableRowElement getRowElement(int index) { return getTrByVisualIndex(index); } /** * Gets the child element that is visually at a certain index. * * @param index * the index of the element to retrieve * @return the element at position {@code index} * @throws IndexOutOfBoundsException * if {@code index} is not valid within {@link #root} */ protected abstract TableRowElement getTrByVisualIndex(int index) throws IndexOutOfBoundsException; protected void paintRemoveColumns(final int offset, final int numberOfColumns) { for (int i = 0; i < getDomRowCount(); i++) { TableRowElement row = getTrByVisualIndex(i); flyweightRow.setup(row, i, columnConfiguration.getCalculatedColumnWidths()); Iterable attachedCells = flyweightRow .getCells(offset, numberOfColumns); getEscalatorUpdater().preDetach(flyweightRow, attachedCells); for (int j = 0; j < numberOfColumns; j++) { row.getCells().getItem(offset).removeFromParent(); } Iterable detachedCells = flyweightRow .getUnattachedCells(offset, numberOfColumns); getEscalatorUpdater().postDetach(flyweightRow, detachedCells); assert flyweightRow.teardown(); } } protected void paintInsertColumns(final int offset, final int numberOfColumns, boolean frozen) { for (int row = 0; row < getDomRowCount(); row++) { final TableRowElement tr = getTrByVisualIndex(row); int logicalRowIndex = getLogicalRowIndex(tr); paintInsertCells(tr, logicalRowIndex, offset, numberOfColumns); } reapplyRowWidths(); if (frozen) { for (int col = offset; col < offset + numberOfColumns; col++) { setColumnFrozen(col, true); } } } /** * Inserts new cell elements into a single row element, invoking * {@link #getEscalatorUpdater()} * {@link EscalatorUpdater#preAttach(Row, Iterable) preAttach} and * {@link EscalatorUpdater#postAttach(Row, Iterable) postAttach} before * and after inserting the cells, respectively. *

* Precondition: The row must be already attached to the DOM and the * FlyweightCell instances corresponding to the new columns added to * {@code flyweightRow}. * * @param tr * the row in which to insert the cells * @param logicalRowIndex * the index of the row * @param offset * the index of the first cell * @param numberOfCells * the number of cells to insert */ private void paintInsertCells(final TableRowElement tr, int logicalRowIndex, final int offset, final int numberOfCells) { assert root.isOrHasChild( tr) : "The row must be attached to the document"; flyweightRow.setup(tr, logicalRowIndex, columnConfiguration.getCalculatedColumnWidths()); Iterable cells = flyweightRow .getUnattachedCells(offset, numberOfCells); for (FlyweightCell cell : cells) { final double colWidth = columnConfiguration .getColumnWidthActual(cell.getColumn()); final TableCellElement cellElem = createCellElement(colWidth); cell.setElement(cellElem); } getEscalatorUpdater().preAttach(flyweightRow, cells); Node referenceCell; if (offset != 0) { referenceCell = tr.getChild(offset - 1); } else { referenceCell = null; } for (FlyweightCell cell : cells) { referenceCell = insertAfterReferenceAndUpdateIt(tr, cell.getElement(), referenceCell); } getEscalatorUpdater().postAttach(flyweightRow, cells); getEscalatorUpdater().update(flyweightRow, cells); assert flyweightRow.teardown(); } public void setColumnFrozen(int column, boolean frozen) { toggleFrozenColumnClass(column, frozen, "frozen"); if (frozen) { updateFreezePosition(column, scroller.lastScrollLeft); } } private void toggleFrozenColumnClass(int column, boolean frozen, String className) { final NodeList childRows = root.getRows(); for (int row = 0; row < childRows.getLength(); row++) { final TableRowElement tr = childRows.getItem(row); if (!rowCanBeFrozen(tr)) { continue; } TableCellElement cell = tr.getCells().getItem(column); if (frozen) { cell.addClassName(className); } else { cell.removeClassName(className); position.reset(cell); } } } public void setColumnLastFrozen(int column, boolean lastFrozen) { toggleFrozenColumnClass(column, lastFrozen, "last-frozen"); } public void updateFreezePosition(int column, double scrollLeft) { final NodeList childRows = root.getRows(); for (int row = 0; row < childRows.getLength(); row++) { final TableRowElement tr = childRows.getItem(row); if (rowCanBeFrozen(tr)) { TableCellElement cell = tr.getCells().getItem(column); position.set(cell, scrollLeft, 0); } } } /** * Checks whether a row is an element, or contains such elements, that * can be frozen. *

* In practice, this applies for all header and footer rows. For body * rows, it applies for all rows except spacer rows. * * @since 7.5.0 * * @param tr * the row element to check whether it, or any of its its * descendants can be frozen * @return true if the given element, or any of its * descendants, can be frozen */ protected abstract boolean rowCanBeFrozen(TableRowElement tr); /** * Iterates through all the cells in a column and returns the width of * the widest element in this RowContainer. * * @param index * the index of the column to inspect * @return the pixel width of the widest element in the indicated column */ public double calculateMaxColWidth(int index) { TableRowElement row = TableRowElement .as(root.getFirstChildElement()); double maxWidth = 0; while (row != null) { final TableCellElement cell = row.getCells().getItem(index); final boolean isVisible = !cell.getStyle().getDisplay() .equals(Display.NONE.getCssName()); if (isVisible) { maxWidth = Math.max(maxWidth, getBoundingWidth(cell)); } row = TableRowElement.as(row.getNextSiblingElement()); } return maxWidth; } /** * Reapplies all the cells' widths according to the calculated widths in * the column configuration. */ public void reapplyColumnWidths() { Element row = root.getFirstChildElement(); while (row != null) { // Only handle non-spacer rows if (!body.spacerContainer.isSpacer(row)) { Element cell = row.getFirstChildElement(); int columnIndex = 0; while (cell != null) { final double width = getCalculatedColumnWidthWithColspan( cell, columnIndex); /* * TODO Should Escalator implement ProvidesResize at * some point, this is where we need to do that. */ cell.getStyle().setWidth(width, Unit.PX); cell = cell.getNextSiblingElement(); columnIndex++; } } row = row.getNextSiblingElement(); } reapplyRowWidths(); } private double getCalculatedColumnWidthWithColspan(final Element cell, final int columnIndex) { final int colspan = cell.getPropertyInt(FlyweightCell.COLSPAN_ATTR); Range spannedColumns = Range.withLength(columnIndex, colspan); /* * Since browsers don't explode with overflowing colspans, escalator * shouldn't either. */ if (spannedColumns.getEnd() > columnConfiguration .getColumnCount()) { spannedColumns = Range.between(columnIndex, columnConfiguration.getColumnCount()); } return columnConfiguration .getCalculatedColumnsWidth(spannedColumns); } /** * Applies the total length of the columns to each row element. *

* Note: In contrast to {@link #reapplyColumnWidths()}, this * method only modifies the width of the {@code * * } element, not the cells within. */ protected void reapplyRowWidths() { double rowWidth = columnConfiguration.calculateRowWidth(); if (rowWidth < 0) { return; } Element row = root.getFirstChildElement(); while (row != null) { // IF there is a rounding error when summing the columns, we // need to round the tr width up to ensure that columns fit and // do not wrap // E.g.122.95+123.25+103.75+209.25+83.52+88.57+263.45+131.21+126.85+113.13=1365.9299999999998 // For this we must set 1365.93 or the last column will wrap row.getStyle().setWidth(WidgetUtil.roundSizeUp(rowWidth), Unit.PX); row = row.getNextSiblingElement(); } } /** * The primary style name for the container. * * @param primaryStyleName * the style name to use as prefix for all row and cell style * names. */ protected void setStylePrimaryName(String primaryStyleName) { String oldStyle = getStylePrimaryName(); if (SharedUtil.equals(oldStyle, primaryStyleName)) { return; } this.primaryStyleName = primaryStyleName; // Update already rendered rows and cells Element row = root.getRows().getItem(0); while (row != null) { UIObject.setStylePrimaryName(row, primaryStyleName + "-row"); Element cell = TableRowElement.as(row).getCells().getItem(0); while (cell != null) { assert TableCellElement.is(cell); UIObject.setStylePrimaryName(cell, primaryStyleName + "-cell"); cell = cell.getNextSiblingElement(); } row = row.getNextSiblingElement(); } } /** * Returns the primary style name of the container. * * @return The primary style name or null if not set. */ protected String getStylePrimaryName() { return primaryStyleName; } @Override public void setDefaultRowHeight(double px) throws IllegalArgumentException { if (px < 1) { throw new IllegalArgumentException( "Height must be positive. " + px + " was given."); } defaultRowHeightShouldBeAutodetected = false; defaultRowHeight = px; reapplyDefaultRowHeights(); applyHeightByRows(); } @Override public double getDefaultRowHeight() { return defaultRowHeight; } /** * The default height of rows has (most probably) changed. *

* Make sure that the displayed rows with a default height are updated * in height and top position. *

* Note:This implementation should not call * {@link Escalator#recalculateElementSizes()} - it is done by the * discretion of the caller of this method. */ protected abstract void reapplyDefaultRowHeights(); protected void reapplyRowHeight(final TableRowElement tr, final double heightPx) { assert heightPx >= 0 : "Height must not be negative"; Element cellElem = tr.getFirstChildElement(); while (cellElem != null) { cellElem.getStyle().setHeight(heightPx, Unit.PX); cellElem = cellElem.getNextSiblingElement(); } /* * no need to apply height to tr-element, it'll be resized * implicitly. */ } protected void setRowPosition(final TableRowElement tr, final int x, final double y) { positions.set(tr, x, y); } /** * Returns the assigned top position for the given element. *

* Note: This method does not calculate what a row's top * position should be. It just returns an assigned value, correct or * not. * * @param tr * the table row element to measure * @return the current top position for {@code tr} * @see BodyRowContainerImpl#getRowTop(int) */ protected double getRowTop(final TableRowElement tr) { return positions.getTop(tr); } protected void removeRowPosition(TableRowElement tr) { positions.remove(tr); } /** * Triggers delayed auto-detection of default row height if it hasn't * been set by that point and the Escalator is both attached and * displayed. */ public void autodetectRowHeightLater() { autodetectingRowHeightLater = true; Scheduler.get().scheduleFinally(() -> { if (defaultRowHeightShouldBeAutodetected && isAttached() && WidgetUtil.isDisplayed(getElement())) { autodetectRowHeightNow(); defaultRowHeightShouldBeAutodetected = false; } autodetectingRowHeightLater = false; }); } @Override public boolean isAutodetectingRowHeightLater() { return autodetectingRowHeightLater; } private void fireRowHeightChangedEventFinally() { if (!rowHeightChangedEventFired) { rowHeightChangedEventFired = true; Scheduler.get().scheduleFinally(() -> { fireEvent(new RowHeightChangedEvent()); rowHeightChangedEventFired = false; }); } } /** * Auto-detect row height immediately, if possible. If Escalator isn't * attached and displayed yet, auto-detecting cannot be performed * correctly. In such cases auto-detecting is left to wait for these * conditions to change, and will be performed when they do. */ public void autodetectRowHeightNow() { if (!isAttached() || !WidgetUtil.isDisplayed(getElement())) { // Run again when attached and displayed defaultRowHeightShouldBeAutodetected = true; return; } final double oldRowHeight = defaultRowHeight; final Element detectionTr = DOM.createTR(); detectionTr.setClassName(getStylePrimaryName() + "-row"); final Element cellElem = DOM.createElement(getCellElementTagName()); cellElem.setClassName(getStylePrimaryName() + "-cell"); cellElem.setInnerText("Ij"); detectionTr.appendChild(cellElem); root.appendChild(detectionTr); double boundingHeight = getBoundingHeight(cellElem); defaultRowHeight = Math.max(1.0d, boundingHeight); root.removeChild(detectionTr); if (root.hasChildNodes()) { reapplyDefaultRowHeights(); applyHeightByRows(); } if (oldRowHeight != defaultRowHeight) { fireRowHeightChangedEventFinally(); } } @Override public Cell getCell(final Element element) { if (element == null) { throw new IllegalArgumentException("Element cannot be null"); } /* * Ensure that element is not root nor the direct descendant of root * (a row or spacer) and ensure the element is inside the dom * hierarchy of the root element. If not, return null. */ if (root == element || element.getParentElement() == root || !root.isOrHasChild(element)) { return null; } /* * Ensure element is the cell element by iterating up the DOM * hierarchy until reaching cell element. */ Element cellElementCandidate = element; while (cellElementCandidate.getParentElement() .getParentElement() != root) { cellElementCandidate = cellElementCandidate.getParentElement(); } final TableCellElement cellElement = TableCellElement .as(cellElementCandidate); // Find dom column int domColumnIndex = -1; for (Element e = cellElement; e != null; e = e .getPreviousSiblingElement()) { domColumnIndex++; } // Find dom row int domRowIndex = -1; for (Element e = cellElement.getParentElement(); e != null; e = e .getPreviousSiblingElement()) { domRowIndex++; } return new Cell(domRowIndex, domColumnIndex, cellElement); } double measureCellWidth(TableCellElement cell, boolean withContent) { /* * To get the actual width of the contents, we need to get the cell * content without any hardcoded height or width. * * But we don't want to modify the existing column, because that * might trigger some unnecessary listeners and whatnot. So, * instead, we make a deep clone of that cell, but without any * explicit dimensions, and measure that instead. */ TableCellElement cellClone = TableCellElement .as((Element) cell.cloneNode(withContent)); cellClone.getStyle().clearHeight(); cellClone.getStyle().clearWidth(); cell.getParentElement().insertBefore(cellClone, cell); double requiredWidth = getBoundingWidth(cellClone); if (BrowserInfo.get().isIE()) { /* * IE browsers have some issues with subpixels. Occasionally * content is overflown even if not necessary. Increase the * counted required size by 0.01 just to be on the safe side. */ requiredWidth += 0.01; } cellClone.removeFromParent(); return requiredWidth; } /** * Gets the minimum width needed to display the cell properly. * * @param colIndex * index of column to measure * @param withContent * true if content is taken into account, * false if not * @return cell width needed for displaying correctly */ double measureMinCellWidth(int colIndex, boolean withContent) { assert isAttached() : "Can't measure max width of cell, since Escalator is not attached to the DOM."; double minCellWidth = -1; NodeList rows = root.getRows(); for (int row = 0; row < rows.getLength(); row++) { TableCellElement cell = rows.getItem(row).getCells() .getItem(colIndex); if (cell != null && !cellIsPartOfSpan(cell)) { double cellWidth = measureCellWidth(cell, withContent); minCellWidth = Math.max(minCellWidth, cellWidth); } } return minCellWidth; } private boolean cellIsPartOfSpan(TableCellElement cell) { boolean cellHasColspan = cell.getColSpan() > 1; boolean cellIsHidden = Display.NONE.getCssName() .equals(cell.getStyle().getDisplay()); return cellHasColspan || cellIsHidden; } void refreshColumns(int index, int numberOfColumns) { if (getRowCount() > 0) { Range rowRange = Range.withLength(0, getRowCount()); Range colRange = Range.withLength(index, numberOfColumns); refreshCells(rowRange, colRange); } } /** * The height of this table section. *

* Note that {@link Escalator#getBody() the body} will calculate its * height, while the others will return a precomputed value. * * @since 7.5.0 * * @return the height of this table section */ protected abstract double getHeightOfSection(); /** * Gets the logical row index for the given table row element. * * @param tr * the table row element inside this container. * @return the logical index of the given element */ public int getLogicalRowIndex(final TableRowElement tr) { // Note: BodyRowContainerImpl overrides this behaviour, since the // physical index and logical index don't match there. For header // and footer there is a match. return tr.getSectionRowIndex(); }; } private abstract class AbstractStaticRowContainer extends AbstractRowContainer { /** The height of the combined rows in the DOM. Never negative. */ private double heightOfSection = 0; public AbstractStaticRowContainer( final TableSectionElement headElement) { super(headElement); } @Override public int getDomRowCount() { return root.getChildCount(); } @Override protected void paintRemoveRows(final int index, final int numberOfRows) { for (int i = index; i < index + numberOfRows; i++) { final TableRowElement tr = root.getRows().getItem(index); paintRemoveRow(tr, index); } recalculateSectionHeight(); } @Override protected TableRowElement getTrByVisualIndex(final int index) throws IndexOutOfBoundsException { if (index >= 0 && index < root.getChildCount()) { return root.getRows().getItem(index); } else { throw new IndexOutOfBoundsException( "No such visual index: " + index); } } @Override public void insertRows(int index, int numberOfRows) { super.insertRows(index, numberOfRows); recalculateElementSizes(); applyHeightByRows(); } @Override public void removeRows(int index, int numberOfRows) { /* * While the rows in a static section are removed, the scrollbar is * temporarily shrunk and then re-expanded. This leads to the fact * that the scroll position is scooted up a bit. This means that we * need to reset the position here. * * If Escalator, at some point, gets a JIT evaluation functionality, * this re-setting is a strong candidate for removal. */ double oldScrollPos = verticalScrollbar.getScrollPos(); super.removeRows(index, numberOfRows); recalculateElementSizes(); applyHeightByRows(); verticalScrollbar.setScrollPos(oldScrollPos); } @Override protected void reapplyDefaultRowHeights() { if (root.getChildCount() == 0) { return; } Profiler.enter( "Escalator.AbstractStaticRowContainer.reapplyDefaultRowHeights"); Element tr = root.getRows().getItem(0); while (tr != null) { reapplyRowHeight(TableRowElement.as(tr), getDefaultRowHeight()); tr = tr.getNextSiblingElement(); } /* * Because all rows are immediately displayed in the static row * containers, the section's overall height has most probably * changed. */ recalculateSectionHeight(); Profiler.leave( "Escalator.AbstractStaticRowContainer.reapplyDefaultRowHeights"); } @Override protected void recalculateSectionHeight() { Profiler.enter( "Escalator.AbstractStaticRowContainer.recalculateSectionHeight"); double newHeight = calculateTotalRowHeight(); if (newHeight != heightOfSection) { heightOfSection = newHeight; sectionHeightCalculated(); /* * We need to update the scrollbar dimension at this point. If * we are scrolled too far down and the static section shrinks, * the body will try to render rows that don't exist during * body.verifyEscalatorCount. This is because the logical row * indices are calculated from the scrollbar position. */ verticalScrollbar.setOffsetSize( heightOfEscalator - header.getHeightOfSection() - footer.getHeightOfSection()); body.verifyEscalatorCount(); body.spacerContainer.updateSpacerDecosVisibility(); } Profiler.leave( "Escalator.AbstractStaticRowContainer.recalculateSectionHeight"); } /** * Informs the row container that the height of its respective table * section has changed. *

* These calculations might affect some layouting logic, such as the * body is being offset by the footer, the footer needs to be readjusted * according to its height, and so on. *

* A table section is either header, body or footer. */ protected abstract void sectionHeightCalculated(); @Override protected void refreshCells(Range logicalRowRange, Range colRange) { assertArgumentsAreValidAndWithinRange(logicalRowRange.getStart(), logicalRowRange.length()); if (!isAttached()) { return; } Profiler.enter("Escalator.AbstractStaticRowContainer.refreshCells"); if (hasColumnAndRowData()) { for (int row = logicalRowRange.getStart(); row < logicalRowRange .getEnd(); row++) { final TableRowElement tr = getTrByVisualIndex(row); refreshRow(tr, row, colRange); } } Profiler.leave("Escalator.AbstractStaticRowContainer.refreshCells"); } @Override protected void paintInsertRows(int visualIndex, int numberOfRows) { paintInsertStaticRows(visualIndex, numberOfRows); } @Override protected boolean rowCanBeFrozen(TableRowElement tr) { assert root.isOrHasChild( tr) : "Row does not belong to this table section"; return true; } @Override protected double getHeightOfSection() { return Math.max(0, heightOfSection); } } private class HeaderRowContainer extends AbstractStaticRowContainer { public HeaderRowContainer(final TableSectionElement headElement) { super(headElement); } @Override protected void sectionHeightCalculated() { double heightOfSection = getHeightOfSection(); bodyElem.getStyle().setMarginTop(heightOfSection, Unit.PX); spacerDecoContainer.getStyle().setMarginTop(heightOfSection, Unit.PX); verticalScrollbar.getElement().getStyle().setTop(heightOfSection, Unit.PX); headerDeco.getStyle().setHeight(heightOfSection, Unit.PX); } @Override protected String getCellElementTagName() { return "th"; } @Override protected AriaGridRole getRowElementRole() { return AriaGridRole.ROWHEADER; } @Override protected AriaGridRole getCellElementRole() { return AriaGridRole.COLUMNHEADER; } @Override public void setStylePrimaryName(String primaryStyleName) { super.setStylePrimaryName(primaryStyleName); UIObject.setStylePrimaryName(root, primaryStyleName + "-header"); } } private class FooterRowContainer extends AbstractStaticRowContainer { public FooterRowContainer(final TableSectionElement footElement) { super(footElement); } @Override public void setStylePrimaryName(String primaryStyleName) { super.setStylePrimaryName(primaryStyleName); UIObject.setStylePrimaryName(root, primaryStyleName + "-footer"); } @Override protected String getCellElementTagName() { return "td"; } @Override protected void sectionHeightCalculated() { double headerHeight = header.getHeightOfSection(); double footerHeight = footer.getHeightOfSection(); int vscrollHeight = (int) Math .floor(heightOfEscalator - headerHeight - footerHeight); final boolean horizontalScrollbarNeeded = columnConfiguration .calculateRowWidth() > widthOfEscalator; if (horizontalScrollbarNeeded) { vscrollHeight -= horizontalScrollbar.getScrollbarThickness(); } footerDeco.getStyle().setHeight(footer.getHeightOfSection(), Unit.PX); verticalScrollbar.setOffsetSize(vscrollHeight); } } private class BodyRowContainerImpl extends AbstractRowContainer implements BodyRowContainer { /* * TODO [[optimize]]: check whether a native JsArray might be faster * than LinkedList */ /** * The order in which row elements are rendered visually in the browser, * with the help of CSS tricks. Usually has nothing to do with the DOM * order. * * @see #sortDomElements() */ private final LinkedList visualRowOrder = new LinkedList<>(); /** * The logical index of the topmost row. * * @deprecated Use the accessors {@link #setTopRowLogicalIndex(int)}, * {@link #updateTopRowLogicalIndex(int)} and * {@link #getTopRowLogicalIndex()} instead */ @Deprecated private int topRowLogicalIndex = 0; /** * A callback function to be executed after new rows are added to the * escalator. */ private Consumer> newEscalatorRowCallback; /** * Set the logical index of the first dom row in visual order. *

* NOTE: this is not necessarily the first dom row in the dom tree, just * the one positioned to the top with CSS. See maintenance notes at the * top of this class for further information. * * @param topRowLogicalIndex * logical index of the first dom row in visual order, might * not match the dom tree order */ private void setTopRowLogicalIndex(int topRowLogicalIndex) { if (LogConfiguration.loggingIsEnabled(Level.INFO)) { Logger.getLogger("Escalator.BodyRowContainer") .fine("topRowLogicalIndex: " + this.topRowLogicalIndex + " -> " + topRowLogicalIndex); } assert topRowLogicalIndex >= 0 : "topRowLogicalIndex became negative (top left cell contents: " + visualRowOrder.getFirst().getCells().getItem(0) .getInnerText() + ") "; /* * if there's a smart way of evaluating and asserting the max index, * this would be a nice place to put it. I haven't found out an * effective and generic solution. */ this.topRowLogicalIndex = topRowLogicalIndex; } /** * Returns the logical index of the first dom row in visual order. This * also gives the offset between the logical and visual indexes. *

* NOTE: this is not necessarily the first dom row in the dom tree, just * the one positioned to the top with CSS. See maintenance notes at the * top of this class for further information. * * @return logical index of the first dom row in visual order, might not * match the dom tree order */ public int getTopRowLogicalIndex() { return topRowLogicalIndex; } /** * Updates the logical index of the first dom row in visual order with * the given difference. *

* NOTE: this is not necessarily the first dom row in the dom tree, just * the one positioned to the top with CSS. See maintenance notes at the * top of this class for further information. * * @param diff * the amount to increase or decrease the logical index of * the first dom row in visual order */ private void updateTopRowLogicalIndex(int diff) { setTopRowLogicalIndex(topRowLogicalIndex + diff); } private class DeferredDomSorter { private static final int SORT_DELAY_MILLIS = 50; // as it happens, 3 frames = 50ms @ 60fps. private static final int REQUIRED_FRAMES_PASSED = 3; private final AnimationCallback frameCounter = new AnimationCallback() { @Override public void execute(double timestamp) { framesPassed++; boolean domWasSorted = sortIfConditionsMet(); if (!domWasSorted) { animationHandle = AnimationScheduler.get() .requestAnimationFrame(this); } else { waiting = false; } } }; private int framesPassed; private double startTime; private AnimationHandle animationHandle; /** true if a sort is scheduled */ public boolean waiting = false; public void reschedule() { waiting = true; resetConditions(); animationHandle = AnimationScheduler.get() .requestAnimationFrame(frameCounter); } private boolean sortIfConditionsMet() { boolean enoughFramesHavePassed = framesPassed >= REQUIRED_FRAMES_PASSED; boolean enoughTimeHasPassed = (Duration.currentTimeMillis() - startTime) >= SORT_DELAY_MILLIS; boolean notTouchActivity = !scroller.touchHandlerBundle.touching; boolean conditionsMet = enoughFramesHavePassed && enoughTimeHasPassed && notTouchActivity; if (conditionsMet) { resetConditions(); sortDomElements(); } return conditionsMet; } private void resetConditions() { if (animationHandle != null) { animationHandle.cancel(); animationHandle = null; } startTime = Duration.currentTimeMillis(); framesPassed = 0; } } private DeferredDomSorter domSorter = new DeferredDomSorter(); private final SpacerContainer spacerContainer = new SpacerContainer(); private boolean insertingOrRemoving = false; public BodyRowContainerImpl(final TableSectionElement bodyElement) { super(bodyElement); } @Override public void setStylePrimaryName(String primaryStyleName) { super.setStylePrimaryName(primaryStyleName); UIObject.setStylePrimaryName(root, primaryStyleName + "-body"); spacerContainer.setStylePrimaryName(primaryStyleName); } public void updateEscalatorRowsOnScroll() { if (visualRowOrder.isEmpty()) { return; } boolean rowsWereMoved = false; final double topElementPosition; final double nextRowBottomOffset; SpacerContainer.SpacerImpl topSpacer = spacerContainer .getSpacer(getTopRowLogicalIndex() - 1); if (topSpacer != null) { topElementPosition = topSpacer.getTop(); nextRowBottomOffset = topSpacer.getHeight() + getDefaultRowHeight(); } else { topElementPosition = getRowTop(visualRowOrder.getFirst()); nextRowBottomOffset = getDefaultRowHeight(); } // TODO [[mpixscroll]] final double scrollTop = tBodyScrollTop; final double sectionHeight = getHeightOfSection(); /* * Calculate how the visual range is situated in relation to the * viewport. Negative value means part of visual range is hidden * above or below the viewport, positive value means there is a gap * at the top or the bottom of the viewport, zero means exact match. * If there is a gap, some rows that are out of view may need to be * recycled from the opposite end. */ final double viewportOffsetTop = topElementPosition - scrollTop; final double viewportOffsetBottom = scrollTop + sectionHeight - getRowTop( getTopRowLogicalIndex() + visualRowOrder.size()); /* * You can only scroll far enough to leave a gap if visualRowOrder * contains a maximal amount of rows and there is at least one more * outside of the visual range. Consequently there can only be a gap * in one end of the viewport at a time. */ if (viewportOffsetTop > 0 || (viewportOffsetTop == 0 && getTopRowLogicalIndex() > 0)) { /* * Scrolling up. Either there's empty room on top, or there * should be a buffer row for tab navigation on top, but there * isn't. */ recycleRowsUpOnScroll(viewportOffsetTop); rowsWereMoved = true; } else if ((viewportOffsetBottom > 0 && (viewportOffsetTop + nextRowBottomOffset <= 0)) || (viewportOffsetBottom == 0 && (getTopRowLogicalIndex() + visualRowOrder.size() < getRowCount() - 2))) { /* * Scrolling down. Either there's empty room at the bottom and * the viewport has been scrolled more than the topmost visual * row, or there should be a buffer row at the bottom to ensure * tab navigation works, but there isn't. */ recycleRowsDownOnScroll(topElementPosition, scrollTop); // Moving rows may have removed more spacers and created another // gap, this time the scroll position needs adjusting. The last // row within visual range should be just below the viewport as // a buffer for helping with tab navigation, unless it's the // last row altogether. int lastRowInVisualRange = getTopRowLogicalIndex() + visualRowOrder.size() - 1; double expectedBottom = getRowTop(lastRowInVisualRange); if (lastRowInVisualRange == getRowCount() - 1) { expectedBottom += getDefaultRowHeight() + spacerContainer .getSpacerHeight(lastRowInVisualRange); } if (expectedBottom < scrollTop + sectionHeight) { double expectedTop = Math.max(0, expectedBottom - sectionHeight); setBodyScrollPosition(tBodyScrollLeft, expectedTop); setScrollTop(expectedTop); } rowsWereMoved = true; } if (rowsWereMoved) { fireRowVisibilityChangeEvent(); // schedule updating of the physical indexes domSorter.reschedule(); } } /** * Recycling rows up for {@link #updateEscalatorRowsOnScroll()}. *

* NOTE: This method should not be called directly from anywhere else. * * @param viewportOffsetTop */ private void recycleRowsUpOnScroll(double viewportOffsetTop) { /* * We can ignore spacers here, because we keep enough rows within * the visual range to fill the viewport completely whether or not * any spacers are shown. There is a small tradeoff of having some * rows rendered even if they are outside of the viewport, but this * simplifies the handling significantly (we can't know what height * any individual spacer has before it has been rendered, which * happens with a delay) and keeps the visual range size stable * while scrolling. Consequently, even if there are spacers within * the current visual range, repositioning this many rows won't * cause us to run out of rows at the bottom. * * The viewportOffsetTop is positive and we round up, and * visualRowOrder can't be empty since we are scrolling, so there is * always going to be at least one row to move. There should also be * one buffer row that actually falls outside of the viewport, in * order to ensure that tabulator navigation works if the rows have * components in them. The buffer row is only needed if filling the * gap doesn't bring us to the top row already. */ int rowsToFillTheGap = (int) Math .ceil(viewportOffsetTop / getDefaultRowHeight()); // ensure we don't try to move more rows than are available // above rowsToFillTheGap = Math.min(rowsToFillTheGap, getTopRowLogicalIndex()); // add the buffer row if there is room for it if (rowsToFillTheGap < getTopRowLogicalIndex()) { ++rowsToFillTheGap; } // we may have scrolled up past all the rows and beyond, can // only recycle as many rows as we have int rowsToRecycle = Math.min(rowsToFillTheGap, visualRowOrder.size()); // select the rows to recycle from the end of the visual range int end = visualRowOrder.size(); int start = end - rowsToRecycle; /* * Calculate the logical index for insertion point based on how many * rows would be needed to fill the gap. Because we are recycling * rows to the top the insertion index will also be the new top row * logical index. */ int newTopRowLogicalIndex = getTopRowLogicalIndex() - rowsToFillTheGap; // recycle the rows and move them to their new positions moveAndUpdateEscalatorRows(Range.between(start, end), 0, newTopRowLogicalIndex); setTopRowLogicalIndex(newTopRowLogicalIndex); } /** * Recycling rows down for {@link #updateEscalatorRowsOnScroll()}. *

* NOTE: This method should not be called directly from anywhere else. * * @param topElementPosition * @param scrollTop */ private void recycleRowsDownOnScroll(double topElementPosition, double scrollTop) { /* * It's better to have any extra rows below than above, so move as * many of them as possible regardless of how many are needed to * fill the gap, as long as one buffer row remains at the top. It * should not be possible to scroll down enough to create a gap * without it being possible to recycle rows to fill the gap, so * viewport itself doesn't need adjusting no matter what. */ // we already have the rows and spacers here and we don't want // to recycle rows that are going to stay visible, so the // spacers have to be taken into account double extraRowPxAbove = getRowHeightsSumBetweenPxExcludingSpacers( topElementPosition, scrollTop); // how many rows fit within that extra space and can be // recycled, rounded towards zero to avoid moving any partially // visible rows int rowsToCoverTheExtra = (int) Math .floor(extraRowPxAbove / getDefaultRowHeight()); // leave one to ensure there is a buffer row to help with tab // navigation if (rowsToCoverTheExtra > 0) { --rowsToCoverTheExtra; } /* * Don't move more rows than there are to move, but also don't move * more rows than should exist at the bottom. However, it's not * possible to scroll down beyond available rows, so there is always * at least one row to recycle. */ int rowsToRecycle = Math.min( Math.min(rowsToCoverTheExtra, visualRowOrder.size()), getRowCount() - getTopRowLogicalIndex() - visualRowOrder.size()); // are only some of the rows getting recycled instead of all // of them boolean partialMove = rowsToRecycle < visualRowOrder.size(); // calculate the logical index where the rows should be moved int logicalTargetIndex; if (partialMove) { /* * We scroll so little that we can just keep adding the rows * immediately below the current escalator. */ logicalTargetIndex = getTopRowLogicalIndex() + visualRowOrder.size(); } else { /* * Since all escalator rows are getting recycled all spacers are * going to get removed and the calculations have to ignore the * spacers again in order to figure out which rows are to be * displayed. In practice we may end up scrolling further down * than the scroll position indicated initially as the spacers * that get removed give room for more rows than expected. * * We can rely on calculations here because there won't be any * old rows left to end up mismatched with. */ logicalTargetIndex = (int) Math .floor(scrollTop / getDefaultRowHeight()); /* * Make sure we don't try to move rows below the actual row * count, even if some of the rows end up hidden at the top as a * result. This won't leave us with any old rows in any case, * because we already checked earlier that there is room to * recycle all the rows. It's only a question of how the new * visual range gets positioned in relation to the viewport. */ if (logicalTargetIndex + visualRowOrder.size() > getRowCount()) { logicalTargetIndex = getRowCount() - visualRowOrder.size(); } } /* * Recycle the rows and move them to their new positions. Since we * are moving the viewport downwards, the visual target index is * always at the bottom and matches the length of the visual range. * Note: Due to how moveAndUpdateEscalatorRows works, this will work * out even if we move all the rows, and try to place them * "at the end". */ moveAndUpdateEscalatorRows(Range.between(0, rowsToRecycle), visualRowOrder.size(), logicalTargetIndex); // top row logical index needs to be updated differently // depending on which update strategy was used, since the rows // are being moved down if (partialMove) { // move down by the amount of recycled rows updateTopRowLogicalIndex(rowsToRecycle); } else { // the insertion index is the new top row logical index setTopRowLogicalIndex(logicalTargetIndex); } } /** * Calculates how much of the given range contains only rows with * spacers excluded. * * @param y1 * start position * @param y2 * end position * @return position difference excluding any space taken up by spacers */ private double getRowHeightsSumBetweenPxExcludingSpacers(double y1, double y2) { assert y1 < y2 : "y1 must be smaller than y2"; double viewportPx = y2 - y1; double spacerPx = spacerContainer.getSpacerHeightsSumBetweenPx(y1, SpacerInclusionStrategy.PARTIAL, y2, SpacerInclusionStrategy.PARTIAL); return viewportPx - spacerPx; } @Override public void insertRows(int index, int numberOfRows) { insertingOrRemoving = true; super.insertRows(index, numberOfRows); insertingOrRemoving = false; if (heightMode == HeightMode.UNDEFINED) { setHeightByRows(getRowCount()); } } @Override public void removeRows(int index, int numberOfRows) { insertingOrRemoving = true; super.removeRows(index, numberOfRows); insertingOrRemoving = false; if (heightMode == HeightMode.UNDEFINED) { setHeightByRows(getRowCount()); } } @Override protected void paintInsertRows(final int index, final int numberOfRows) { assert index >= 0 && index < getRowCount() : "Attempting to insert a row " + "outside of the available range."; assert numberOfRows > 0 : "Attempting to insert a non-positive " + "amount of rows, something must be wrong."; if (numberOfRows <= 0) { return; } /* * NOTE: this method handles and manipulates logical, visual, and * physical indexes a lot. If you don't remember what those mean and * how they relate to each other, see the top of this class for * Maintenance Notes. * * At the beginning of this method the logical index of the data * provider has already been updated to include the new rows, but * visual and physical indexes have not, nor has the spacer indexing * been updated, and the topRowLogicalIndex may be out of date as * well. */ // top of visible area before any rows are actually added double scrollTop = getScrollTop(); // logical index of the first row within the visual range before any // rows are actually added int oldTopRowLogicalIndex = getTopRowLogicalIndex(); // length of the visual range before any rows are actually added int oldVisualRangeLength = visualRowOrder.size(); /* * If there is room for more dom rows within the maximum visual * range, add them. Calling this method repositions all the rows and * spacers below the insertion point and updates the spacer indexes * accordingly. * * TODO: Details rows should be added and populated here, since they * have variable heights and affect the position calculations. * Currently that's left to be triggered at the end and with a * delay. If any new spacers exist, everything below them is going * to be repositioned again for every spacer addition. */ final List addedRows = fillAndPopulateEscalatorRowsIfNeeded( index - oldTopRowLogicalIndex, index, numberOfRows); // is the insertion point for new rows below visual range (viewport // is irrelevant) final boolean newRowsInsertedBelowVisualRange = index >= oldVisualRangeLength + oldTopRowLogicalIndex; // is the insertion point for new rows above initial visual range final boolean newRowsInsertedAboveVisualRange = index <= oldTopRowLogicalIndex; // is the insertion point for new rows above viewport final boolean newRowsInsertedAboveCurrentViewport = getRowTop( index) < scrollTop; if (newRowsInsertedBelowVisualRange) { /* * There is no change to scroll position, and all other changes * to positioning and indexing are out of visual range or * already done (if addedRows is not empty). */ } else if (newRowsInsertedAboveVisualRange && addedRows.isEmpty() && newRowsInsertedAboveCurrentViewport) { /* * This section can only be reached if the insertion point is * above the visual range, the visual range already covers a * maximal amount of rows, and we are scrolled down enough that * the top row is either partially or completely hidden. The * last two points happen by default if the first row of the * visual range has any other logical index than zero. Any other * use cases involving the top row within the visual range need * different handling. */ paintInsertRowsAboveViewPort(index, numberOfRows, oldTopRowLogicalIndex); } else if (newRowsInsertedAboveCurrentViewport) { /* * Rows were inserted within the visual range but above the * viewport. This includes the use case where the insertion * point is just above the visual range and we are scrolled down * a bit but the visual range doesn't have maximal amount of * rows yet (can only happen with spacers in play), so more rows * were added to the visual range but no rows need to be * recycled. */ paintInsertRowsWithinVisualRangeButAboveViewport(index, numberOfRows, oldTopRowLogicalIndex, addedRows.size()); } else { /* * Rows added within visual range and either within or below the * viewport. Recycled rows come from the END of the visual * range. */ paintInsertRowsWithinVisualRangeAndWithinOrBelowViewport(index, numberOfRows, oldTopRowLogicalIndex, addedRows.size()); } /* * Calling insertRows will always change the number of rows - update * the scrollbar sizes. This calculation isn't affected by actual * dom rows amount or contents except for spacer heights. Spacers * that don't fit the visual range are considered to have no height * and might affect scrollbar calculations aversely, but that can't * be avoided since they have unknown and variable heights. */ scroller.recalculateScrollbarsForVirtualViewport(); } /** * Row insertion handling for {@link #paintInsertRows(int, int)} when * the range will be inserted above the visual range. *

* NOTE: This method should not be called directly from anywhere else. * * @param index * @param numberOfRows * @param oldTopRowLogicalIndex */ private void paintInsertRowsAboveViewPort(int index, int numberOfRows, int oldTopRowLogicalIndex) { /* * Because there is no need to expand the visual range, no row or * spacer contents get updated. All rows, spacers, and scroll * position simply need to be shifted down accordingly and the * spacer indexes need updating. */ spacerContainer.updateSpacerIndexesForRowAndAfter(index, oldTopRowLogicalIndex + visualRowOrder.size(), numberOfRows); // height of a single row double defaultRowHeight = getDefaultRowHeight(); // height of new rows, out of visual range so spacers assumed to // have no height double newRowsHeight = numberOfRows * defaultRowHeight; // update the positions moveViewportAndContent(index, newRowsHeight, newRowsHeight, newRowsHeight); // top row logical index moves down by the number of new rows updateTopRowLogicalIndex(numberOfRows); } /** * Row insertion handling for {@link #paintInsertRows(int, int)} when * the range will be inserted within the visual range above the * viewport. *

* NOTE: This method should not be called directly from anywhere else. * * @param index * @param numberOfRows * @param oldTopRowLogicalIndex * @param addedRowCount */ private void paintInsertRowsWithinVisualRangeButAboveViewport(int index, int numberOfRows, int oldTopRowLogicalIndex, int addedRowCount) { /* * Unless we are scrolled all the way to the top the visual range is * always out of view because we need a buffer row for tabulator * navigation. Depending on the scroll position and spacers there * might even be several rendered rows above the viewport, * especially when we are scrolled all the way to the bottom. * * Even though the new rows will be initially out of view they still * need to be correctly populated and positioned. Their contents * won't be refreshed if they become visible later on (e.g. when a * spacer gets hidden, which causes more rows to fit within the * viewport) because they are expected to be already up to date. * * Note that it's not possible to insert content so that it's * partially visible at the top. A partially visible row at top will * still be the exact same partially visible row after the * insertion, no matter which side of that row the new content gets * inserted to. This section handles the use case where the new * content is inserted above the partially visible row. * * Because the insertion point is out of view above the viewport, * the only thing that should change for the end user visually is * the scroll handle, which gets a new position and possibly turns a * bit smaller if a lot of rows got inserted. * * From a technical point of view this also means that any rows that * might need to get recycled should be taken from the BEGINNING of * the visual range, above the insertion point. There might still be * some "extra" rows below the viewport as well, but those should be * left alone. They are going to be needed where they are if any * spacers get closed or reduced in size. * * On a practical level we need to tweak the virtual viewport -- * scroll handle positions, row and spacer positions, and ensure the * scroll area height is calculated correctly. Viewport should * remain in a fixed position in relation to the existing rows and * display no new rows. If any rows get recycled and have spacers * either before or after the update the height of those spacers * affects the position calculations. * * Insertion point can be anywhere from just before the previous * first row of the visual range to just before the first actually * visible row. The insertion shifts down the content below * insertion point, which excludes any dom rows that remain above * the insertion point after recycling is finished. After the rows * below insertion point have been moved the viewport needs to be * shifted down a similar amount to regain its old relative position * again. * * The visual range only ever contains at most as many rows as would * fit within the viewport without any spacers with one extra row on * both at the top and at the bottom as buffer rows, so the amount * of rows that needs to be checked is always reasonably limited. */ // insertion index within the visual range int visualTargetIndex = index - oldTopRowLogicalIndex; // how many dom rows before insertion point versus how many new // rows didn't get their own dom rows -- smaller amount // determines how many rows can and need to be recycled int rowsToUpdate = Math.min(visualTargetIndex, numberOfRows - addedRowCount); // height of a single row double defaultRowHeight = getDefaultRowHeight(); boolean rowVisibilityChanged = false; if (rowsToUpdate > 0) { // recycle the rows and update the positions, adjust // logical index for inserted rows that won't fit within // visual range int logicalIndex = index + numberOfRows - rowsToUpdate; if (visualTargetIndex > 0) { // move after any added dom rows moveAndUpdateEscalatorRows(Range.between(0, rowsToUpdate), visualTargetIndex + addedRowCount, logicalIndex); } else { // move before any added dom rows moveAndUpdateEscalatorRows(Range.between(0, rowsToUpdate), visualTargetIndex, logicalIndex); } // adjust viewport down to maintain the initial position double newRowsHeight = numberOfRows * defaultRowHeight; double newSpacerHeights = spacerContainer .getSpacerHeightsSumUntilIndex( logicalIndex + rowsToUpdate) - spacerContainer.getSpacerHeightsSumUntilIndex(index); /* * FIXME: spacers haven't been added yet and they can cause * escalator contents to shift after the fact in a way that * can't be countered for here. * * FIXME: verticalScrollbar internal state causes this update to * fail partially and the next attempt at scrolling causes * things to jump. * * Couldn't find a quick fix to either problem and this use case * is somewhat marginal so left them here for now. */ moveViewportAndContent(null, 0, 0, newSpacerHeights + newRowsHeight); rowVisibilityChanged = true; } else { // no rows to recycle but update the spacer indexes spacerContainer.updateSpacerIndexesForRowAndAfter(index, index + numberOfRows - addedRowCount, numberOfRows - addedRowCount); double newRowsHeight = numberOfRows * defaultRowHeight; if (addedRowCount > 0) { // update the viewport, rows and spacers were // repositioned already by the method for adding dom // rows moveViewportAndContent(null, 0, 0, newRowsHeight); rowVisibilityChanged = true; } else { // all changes are actually above the viewport after // all, update all positions moveViewportAndContent(index, newRowsHeight, newRowsHeight, newRowsHeight); } } if (numberOfRows > addedRowCount) { /* * If there are more new rows than how many new dom rows got * added, the top row logical index necessarily gets shifted * down by that difference because recycling doesn't replace any * logical rows, just shifts them off the visual range, and the * inserted rows that don't fit to the visual range also push * the other rows down. If every new row got new dom rows as * well the top row logical index doesn't change, because the * insertion point was within the visual range. */ updateTopRowLogicalIndex(numberOfRows - addedRowCount); } if (rowVisibilityChanged) { fireRowVisibilityChangeEvent(); } if (rowsToUpdate > 0) { // update the physical index sortDomElements(); } } /** * Row insertion handling for {@link #paintInsertRows(int, int)} when * the range will be inserted within the visual range either within or * below the viewport. *

* NOTE: This method should not be called directly from anywhere else. * * @param index * @param numberOfRows * @param oldTopRowLogicalIndex * @param addedRowCount */ private void paintInsertRowsWithinVisualRangeAndWithinOrBelowViewport( int index, int numberOfRows, int oldTopRowLogicalIndex, int addedRowCount) { // insertion index within the visual range int visualIndex = index - oldTopRowLogicalIndex; // how many dom rows after insertion point versus how many new // rows to add -- smaller amount determines how many rows can or // need to be recycled, excluding the rows that already got new // dom rows int rowsToUpdate = Math.max( Math.min(visualRowOrder.size() - visualIndex, numberOfRows) - addedRowCount, 0); if (rowsToUpdate > 0) { moveAndUpdateEscalatorRows( Range.between(visualRowOrder.size() - rowsToUpdate, visualRowOrder.size()), visualIndex + addedRowCount, index + addedRowCount); fireRowVisibilityChangeEvent(); // update the physical index sortDomElements(); } } /** * Move escalator rows around, and make sure everything gets * appropriately repositioned and repainted. In the case of insertion or * removal, following spacer indexes get updated as well. * * @param visualSourceRange * the range of rows to move to a new place * @param visualTargetIndex * the visual index where the rows will be placed to * @param logicalTargetIndex * the logical index to be assigned to the first moved row */ private void moveAndUpdateEscalatorRows(final Range visualSourceRange, final int visualTargetIndex, final int logicalTargetIndex) throws IllegalArgumentException { if (visualSourceRange.isEmpty()) { return; } int sourceRangeLength = visualSourceRange.length(); int domRowCount = getDomRowCount(); int rowCount = getRowCount(); assert visualSourceRange.getStart() >= 0 : "Visual source start " + "must be 0 or greater (was " + visualSourceRange.getStart() + ")"; assert logicalTargetIndex >= 0 : "Logical target must be 0 or " + "greater (was " + logicalTargetIndex + ")"; assert visualTargetIndex >= 0 : "Visual target must be 0 or greater (was " + visualTargetIndex + ")"; assert visualTargetIndex <= domRowCount : "Visual target " + "must not be greater than the number of escalator rows (was " + visualTargetIndex + ", escalator rows " + domRowCount + ")"; assert logicalTargetIndex + sourceRangeLength <= rowCount : "Logical " + "target leads to rows outside of the data range (" + Range.withLength(logicalTargetIndex, sourceRangeLength) + " goes beyond " + Range.withLength(0, rowCount) + ")"; /* * Since we move a range into another range, the indices might move * about. Having 10 rows, if we move 0..1 to index 10 (to the end of * the collection), the target range will end up being 8..9, instead * of 10..11. * * This applies only if we move elements forward in the collection, * not backward. */ final int adjustedVisualTargetIndex; if (visualSourceRange.getStart() < visualTargetIndex) { adjustedVisualTargetIndex = visualTargetIndex - sourceRangeLength; } else { adjustedVisualTargetIndex = visualTargetIndex; } int oldTopRowLogicalIndex = getTopRowLogicalIndex(); // first moved row's logical index before move int oldSourceRangeLogicalStart = oldTopRowLogicalIndex + visualSourceRange.getStart(); // new top row logical index int newTopRowLogicalIndex = logicalTargetIndex - adjustedVisualTargetIndex; // variables for update types that require special handling boolean recycledToTop = logicalTargetIndex < oldTopRowLogicalIndex; boolean recycledFromTop = visualSourceRange.getStart() == 0; boolean scrollingUp = recycledToTop && visualSourceRange.getEnd() == visualRowOrder.size(); boolean scrollingDown = recycledFromTop && logicalTargetIndex >= oldTopRowLogicalIndex + visualRowOrder.size(); if (visualSourceRange.getStart() != adjustedVisualTargetIndex) { /* * Reorder the rows to their correct places within * visualRowOrder (unless rows are moved back to their original * places) */ /* * TODO [[optimize]]: move whichever set is smaller: the ones * explicitly moved, or the others. So, with 10 escalator rows, * if we are asked to move idx[0..8] to the end of the list, * it's faster to just move idx[9] to the beginning. */ final List removedRows = new ArrayList<>( sourceRangeLength); for (int i = 0; i < sourceRangeLength; i++) { final TableRowElement tr = visualRowOrder .remove(visualSourceRange.getStart()); removedRows.add(tr); } visualRowOrder.addAll(adjustedVisualTargetIndex, removedRows); } // refresh contents of rows to be recycled, returns the combined // height of the spacers that got removed from visual range double spacerHeightsOfRecycledRowsBefore = refreshRecycledRowContents( logicalTargetIndex, adjustedVisualTargetIndex, sourceRangeLength, oldSourceRangeLogicalStart); boolean movedDown = adjustedVisualTargetIndex != visualTargetIndex; boolean recycledToOrFromTop = recycledToTop || recycledFromTop; // update spacer indexes unless we are scrolling -- with scrolling // the remaining spacers are where they belong, the recycled ones // were already removed, and new ones will be added with delay if (!(scrollingUp || scrollingDown)) { if (recycledToOrFromTop) { updateSpacerIndexesForMoveWhenRecycledToOrFromTop( oldSourceRangeLogicalStart, sourceRangeLength, oldTopRowLogicalIndex, newTopRowLogicalIndex, recycledFromTop); } else { updateSpacerIndexesForMoveWhenNotRecycledToOrFromTop( logicalTargetIndex, oldSourceRangeLogicalStart, sourceRangeLength, movedDown); } } // Would be useful if new spacer heights could be determined // here already but their contents are populated with delay. // If the heights ever become available immediately, the // handling that follows needs to be updated to take the new // spacer heights into account. repositionMovedRows(adjustedVisualTargetIndex, sourceRangeLength, newTopRowLogicalIndex); // variables for reducing the amount of necessary parameters boolean scrollingDownAndNoSpacersRemoved = scrollingDown && spacerHeightsOfRecycledRowsBefore <= 0d; boolean spacerHeightsChanged = spacerHeightsOfRecycledRowsBefore > 0d; repositionRowsShiftedByTheMove(visualSourceRange, visualTargetIndex, adjustedVisualTargetIndex, newTopRowLogicalIndex, scrollingDownAndNoSpacersRemoved, scrollingUp, recycledToTop); repositionRowsBelowMovedAndShiftedIfNeeded(visualSourceRange, visualTargetIndex, adjustedVisualTargetIndex, newTopRowLogicalIndex, (scrollingUp || scrollingDown), recycledToOrFromTop, spacerHeightsChanged); } /** * Refresh the contents of the affected rows for * {@link #moveAndUpdateEscalatorRows(Range, int, int)} *

* NOTE: This method should not be called directly from anywhere else. * * @param logicalTargetIndex * @param adjustedVisualTargetIndex * @param sourceRangeLength * @param spacerHeightsBeforeMoveTotal * @param oldSourceRangeLogicalStart * @return the combined height of any removed spacers */ private double refreshRecycledRowContents(int logicalTargetIndex, int adjustedVisualTargetIndex, int sourceRangeLength, int oldSourceRangeLogicalStart) { final ListIterator iter = visualRowOrder .listIterator(adjustedVisualTargetIndex); double removedSpacerHeights = 0d; for (int i = 0; i < sourceRangeLength; ++i) { final TableRowElement tr = iter.next(); int logicalIndex = logicalTargetIndex + i; // clear old spacer SpacerContainer.SpacerImpl spacer = spacerContainer .getSpacer(oldSourceRangeLogicalStart + i); if (spacer != null) { double spacerHeight = spacer.getHeight(); removedSpacerHeights += spacerHeight; spacerContainer .removeSpacer(oldSourceRangeLogicalStart + i); } refreshRow(tr, logicalIndex); } return removedSpacerHeights; } /** * Update the spacer indexes to correspond with logical indexes for * {@link #moveAndUpdateEscalatorRows(Range, int, int)} when the move * recycles rows to or from top *

* NOTE: This method should not be called directly from anywhere else. * * @param oldSourceRangeLogicalStart * @param sourceRangeLength * @param oldTopRowLogicalIndex * @param newTopRowLogicalIndex * @param recycledFromTop */ private void updateSpacerIndexesForMoveWhenRecycledToOrFromTop( int oldSourceRangeLogicalStart, int sourceRangeLength, int oldTopRowLogicalIndex, int newTopRowLogicalIndex, boolean recycledFromTop) { if (recycledFromTop) { // first rows are getting recycled thanks to insertion or // removal, all the indexes below need to be updated // accordingly int indexesToShift; if (newTopRowLogicalIndex != oldTopRowLogicalIndex) { indexesToShift = newTopRowLogicalIndex - oldTopRowLogicalIndex; } else { indexesToShift = -sourceRangeLength; } spacerContainer.updateSpacerIndexesForRowAndAfter( oldSourceRangeLogicalStart + sourceRangeLength, oldTopRowLogicalIndex + visualRowOrder.size(), indexesToShift); } else { // rows recycled to the top, move the remaining spacer // indexes up spacerContainer.updateSpacerIndexesForRowAndAfter( oldSourceRangeLogicalStart + sourceRangeLength, getRowCount() + sourceRangeLength, -sourceRangeLength); } } /** * Update the spacer indexes to correspond with logical indexes for * {@link #moveAndUpdateEscalatorRows(Range, int, int)} when the move * does not recycle rows to or from top *

* NOTE: This method should not be called directly from anywhere else. * * @param logicalTargetIndex * @param oldSourceRangeLogicalStart * @param sourceRangeLength * @param movedDown */ private void updateSpacerIndexesForMoveWhenNotRecycledToOrFromTop( int logicalTargetIndex, int oldSourceRangeLogicalStart, int sourceRangeLength, boolean movedDown) { if (movedDown) { // move the shifted spacer indexes up to fill the freed // space spacerContainer.updateSpacerIndexesForRowAndAfter( oldSourceRangeLogicalStart + sourceRangeLength, logicalTargetIndex + sourceRangeLength, -sourceRangeLength); } else { // move the shifted spacer indexes down to fill the freed // space spacerContainer.updateSpacerIndexesForRowAndAfter( logicalTargetIndex, oldSourceRangeLogicalStart, sourceRangeLength); } } /** * Reposition the rows that were moved for * {@link #moveAndUpdateEscalatorRows(Range, int, int)} *

* NOTE: This method should not be called directly from anywhere else. * * @param adjustedVisualTargetIndex * @param sourceRangeLength * @param newTopRowLogicalIndex */ private void repositionMovedRows(int adjustedVisualTargetIndex, int sourceRangeLength, int newTopRowLogicalIndex) { int start = adjustedVisualTargetIndex; updateRowPositions(newTopRowLogicalIndex + start, start, sourceRangeLength); } /** * Reposition the rows that were shifted by the move for * {@link #moveAndUpdateEscalatorRows(Range, int, int)} *

* NOTE: This method should not be called directly from anywhere else. * * @param visualSourceRange * @param visualTargetIndex * @param adjustedVisualTargetIndex * @param newTopRowLogicalIndex * @param scrollingDownAndNoSpacersRemoved * @param scrollingUp * @param recycledToTop */ private void repositionRowsShiftedByTheMove(Range visualSourceRange, int visualTargetIndex, int adjustedVisualTargetIndex, int newTopRowLogicalIndex, boolean scrollingDownAndNoSpacersRemoved, boolean scrollingUp, boolean recycledToTop) { if (visualSourceRange.length() == visualRowOrder.size()) { // all rows got updated and were repositioned already return; } if (scrollingDownAndNoSpacersRemoved || scrollingUp) { // scrolling, no spacers got removed from or added above any // remaining rows so everything is where it belongs already // (there is no check for added spacers because adding happens // with delay, whether any spacers are coming or not they don't // exist yet and thus can't be taken into account here) return; } if (adjustedVisualTargetIndex != visualTargetIndex) { // rows moved down, shifted rows need to be moved up int start = visualSourceRange.getStart(); updateRowPositions(newTopRowLogicalIndex + start, start, adjustedVisualTargetIndex - start); } else { // rows moved up, shifted rows need to be repositioned // unless it's just a recycling and no spacer heights // above got updated if (recycledToTop) { // rows below the shifted ones need to be moved up (which is // done in the next helper method) but the shifted rows // themselves are already where they belong // (this should only be done if no spacers were added, but // we can't know that yet so we'll have to adjust for them // afterwards if any do appear) return; } int start = adjustedVisualTargetIndex + visualSourceRange.length(); updateRowPositions(newTopRowLogicalIndex + start, start, visualSourceRange.getEnd() - start); } } /** * If necessary, reposition the rows that are below those rows that got * moved or shifted for * {@link #moveAndUpdateEscalatorRows(Range, int, int)} *

* NOTE: This method should not be called directly from anywhere else. * * @param visualSourceRange * @param visualTargetIndex * @param adjustedVisualTargetIndex * @param newTopRowLogicalIndex * @param scrolling * @param recycledToOrFromTop * @param spacerHeightsChanged */ private void repositionRowsBelowMovedAndShiftedIfNeeded( Range visualSourceRange, int visualTargetIndex, int adjustedVisualTargetIndex, int newTopRowLogicalIndex, boolean scrolling, boolean recycledToOrFromTop, boolean spacerHeightsChanged) { /* * There is no need to check if any rows preceding the source and * target range need their positions adjusted, but rows below both * may very well need it if spacer heights changed or rows got * inserted or removed instead of just moved around. * * When scrolling to either direction all the rows already got * processed by earlier stages, there are no unprocessed rows left * either above or below. */ if (!scrolling && (recycledToOrFromTop || spacerHeightsChanged)) { int firstBelow; if (adjustedVisualTargetIndex != visualTargetIndex) { // rows moved down firstBelow = adjustedVisualTargetIndex + visualSourceRange.length(); } else { // rows moved up firstBelow = visualSourceRange.getEnd(); } updateRowPositions(newTopRowLogicalIndex + firstBelow, firstBelow, visualRowOrder.size() - firstBelow); } } @Override public void updateRowPositions(int index, int numberOfRows) { Range visibleRowRange = getVisibleRowRange(); Range rangeToUpdate = Range.withLength(index, numberOfRows); Range intersectingRange = visibleRowRange .partitionWith(rangeToUpdate)[1]; if (intersectingRange.isEmpty()) { // no overlap with the visual range, ignore the positioning return; } int adjustedIndex = intersectingRange.getStart(); int adjustedVisualIndex = adjustedIndex - getTopRowLogicalIndex(); updateRowPositions(adjustedIndex, adjustedVisualIndex, intersectingRange.length()); // make sure there is no unnecessary gap adjustScrollPositionIfNeeded(); scroller.recalculateScrollbarsForVirtualViewport(); } /** * Re-calculates and updates the positions of rows and spacers within * the given range. Doesn't touch the scroll positions. * * @param logicalIndex * logical index of the first row to reposition * @param visualIndex * visual index of the first row to reposition * @param numberOfRows * the number of rows to reposition */ private void updateRowPositions(int logicalIndex, int visualIndex, int numberOfRows) { double newRowTop = getRowTop(logicalIndex); for (int i = 0; i < numberOfRows; ++i) { TableRowElement tr = visualRowOrder.get(visualIndex + i); setRowPosition(tr, 0, newRowTop); newRowTop += getDefaultRowHeight(); SpacerContainer.SpacerImpl spacer = spacerContainer .getSpacer(logicalIndex + i); if (spacer != null) { spacer.setPosition(0, newRowTop); newRowTop += spacer.getHeight(); } } } /** * Checks whether there is an unexpected gap below the visible rows and * adjusts the viewport if necessary. */ private void adjustScrollPositionIfNeeded() { double scrollTop = getScrollTop(); int firstBelowVisualRange = getTopRowLogicalIndex() + visualRowOrder.size(); double gapBelow = scrollTop + getHeightOfSection() - getRowTop(firstBelowVisualRange); boolean bufferRowNeeded = gapBelow == 0 && firstBelowVisualRange < getRowCount(); if (scrollTop > 0 && (gapBelow > 0 || bufferRowNeeded)) { /* * This situation can be reached e.g. by removing a spacer. * Scroll position must be adjusted accordingly but no more than * there is room to scroll up. If a buffer row is needed make * sure the last row ends up at least slightly below the * viewport. */ double adjustedGap = Math.max(gapBelow, bufferRowNeeded ? 1 : 0); double yDeltaScroll = Math.min(adjustedGap, scrollTop); moveViewportAndContent(null, 0, 0, -yDeltaScroll); } } /** * Adjust the scroll position and move the contained rows. *

* The difference between using this method and simply scrolling is that * this method "takes the rows and spacers with it" and renders them * appropriately. The viewport may be scrolled any arbitrary amount, and * the contents are moved appropriately, but always snapped into a * plausible place. *

*

*
Example 1
*
An Escalator with default row height 20px. Adjusting the scroll * position with 7.5px will move the viewport 7.5px down, but leave the * row where it is.
*
Example 2
*
An Escalator with default row height 20px. Adjusting the scroll * position with 27.5px will move the viewport 27.5px down, and place * the row at 20px.
*
* * @deprecated This method isn't used by Escalator anymore since Vaadin * 8.9 and the general row handling logic has been * rewritten, so attempting to call this method may lead to * unexpected consequences. This method is likely to get * removed soon. * @param yDelta * the delta of pixels by which to move the viewport and * content. A positive value moves everything downwards, * while a negative value moves everything upwards */ @Deprecated public void moveViewportAndContent(final double yDelta) { if (yDelta == 0) { return; } double newTop = tBodyScrollTop + yDelta; verticalScrollbar.setScrollPos(newTop); final double defaultRowHeight = getDefaultRowHeight(); double rowPxDelta = yDelta - (yDelta % defaultRowHeight); int rowIndexDelta = (int) (yDelta / defaultRowHeight); if (!WidgetUtil.pixelValuesEqual(rowPxDelta, 0)) { Collection spacers = spacerContainer .getSpacersAfterPx(tBodyScrollTop, SpacerInclusionStrategy.PARTIAL); for (SpacerContainer.SpacerImpl spacer : spacers) { spacer.setPositionDiff(0, rowPxDelta); spacer.setRowIndex(spacer.getRow() + rowIndexDelta); } for (TableRowElement tr : visualRowOrder) { setRowPosition(tr, 0, getRowTop(tr) + rowPxDelta); } } setBodyScrollPosition(tBodyScrollLeft, newTop); } /** * Move rows, spacers, and/or viewport up or down. For rows and spacers * either everything within visual range is affected (index * {@code null}) or only those from the given row index forward. *

* This method does not update spacer indexes. * * @param index * the logical index from which forward the rows and spacers * should be updated, or {@code null} if all of them * @param yDeltaRows * how much rows should be shifted in pixels * @param yDeltaSpacers * how much spacers should be shifted in pixels * @param yDeltaScroll * how much scroll position should be shifted in pixels */ private void moveViewportAndContent(Integer index, final double yDeltaRows, final double yDeltaSpacers, final double yDeltaScroll) { if (!WidgetUtil.pixelValuesEqual(yDeltaScroll, 0d)) { double newTop = tBodyScrollTop + yDeltaScroll; verticalScrollbar.setScrollPos(newTop); setBodyScrollPosition(tBodyScrollLeft, newTop); } if (!WidgetUtil.pixelValuesEqual(yDeltaSpacers, 0d)) { Collection spacers; if (index == null) { spacers = spacerContainer.getSpacersAfterPx(tBodyScrollTop, SpacerInclusionStrategy.PARTIAL); } else { spacers = spacerContainer.getSpacersForRowAndAfter(index); } for (SpacerContainer.SpacerImpl spacer : spacers) { spacer.setPositionDiff(0, yDeltaSpacers); } } if (!WidgetUtil.pixelValuesEqual(yDeltaRows, 0d)) { if (index == null) { // move all visible rows to the desired direction for (TableRowElement tr : visualRowOrder) { setRowPosition(tr, 0, getRowTop(tr) + yDeltaRows); } } else { // move all visible rows, including the index row, to the // desired direction shiftRowPositions(index - 1, yDeltaRows); } } } /** * Adds new physical escalator rows to the DOM at the given visual index * if there's still a need for more escalator rows. *

* If Escalator already is at (or beyond) max capacity, this method does * nothing to the DOM. *

* Calling this method repositions all the rows and spacers below the * insertion point. * * @param visualIndex * the index at which to add new escalator rows to DOM * @param logicalIndex * the logical index that corresponds with the first new * escalator row, should usually be the same as visual index * because there is still need for new rows, but this is not * always the case e.g. if row height is changed * @param numberOfRows * the number of rows to add at index * @return a list of the added rows */ private List fillAndPopulateEscalatorRowsIfNeeded( final int visualIndex, final int logicalIndex, final int numberOfRows) { /* * We want to maintain enough rows to fill the entire viewport even * if their spacers have no height. If their spacers do have height * some of these rows may end up outside of the viewport, but that's * ok. */ final int escalatorRowsStillFit = getMaxVisibleRowCount() - getDomRowCount(); final int escalatorRowsNeeded = Math.min(numberOfRows, escalatorRowsStillFit); if (escalatorRowsNeeded > 0) { int rowsBeforeAddition = visualRowOrder.size(); // this is AbstractRowContainer method and not easily overridden // to consider logical indexes separately from visual indexes, // so as a workaround we create the rows as if those two were // the same and then update the contents if needed final List addedRows = paintInsertStaticRows( visualIndex, escalatorRowsNeeded); visualRowOrder.addAll(visualIndex, addedRows); if (visualIndex != logicalIndex) { // row got populated with wrong contents, need to update int adjustedLogicalIndex = 0; if (visualIndex == 0) { // added to the beginning of visual range, use the // end of insertion range because the beginning might // not fit completely adjustedLogicalIndex = logicalIndex + numberOfRows - addedRows.size(); } else { // added anywhere else, use the beginning of // insertion range and the rest of the rows get // recycled below if there is room for them adjustedLogicalIndex = logicalIndex; } for (int i = 0; i < addedRows.size(); ++i) { TableRowElement tr = addedRows.get(i); refreshRow(tr, adjustedLogicalIndex + i); } } // if something is getting inserted instead of just being // brought to visual range, the rows below the insertion point // need to have their spacer indexes updated accordingly if (logicalIndex >= getTopRowLogicalIndex() && visualIndex < rowsBeforeAddition) { spacerContainer.updateSpacerIndexesForRowAndAfter( logicalIndex, getRowCount(), addedRows.size()); } // update the positions of the added rows and the rows below // them // TODO: this can lead to moving things around twice in case // some rows didn't get new dom rows (e.g. when expanding a // TreeGrid node with more children than can fit within the max // visual range size), consider moving this update elsewhere double rowTop = getRowTop(logicalIndex); for (int i = visualIndex; i < visualRowOrder.size(); i++) { final TableRowElement tr = visualRowOrder.get(i); setRowPosition(tr, 0, rowTop); rowTop += getDefaultRowHeight(); SpacerContainer.SpacerImpl spacer = spacerContainer .getSpacer(logicalIndex - visualIndex + i); if (spacer != null) { spacer.setPosition(0, rowTop); rowTop += spacer.getHeight(); } } // Execute the registered callback function for newly created // rows Optional.ofNullable(newEscalatorRowCallback) .ifPresent(callback -> callback.accept(addedRows)); return addedRows; } else { return Collections.emptyList(); } } private int getMaxVisibleRowCount() { double heightOfSection = getHeightOfSection(); // By including the possibly shown scrollbar height, we get a // consistent count and do not add/remove rows whenever a scrollbar // is shown. Make sure that two extra rows are included for // assisting with tab navigation on both sides of the viewport. heightOfSection += horizontalScrollbarDeco.getOffsetHeight(); double defaultRowHeight = getDefaultRowHeight(); final int maxVisibleRowCount = (int) Math .ceil(heightOfSection / defaultRowHeight) + 2; /* * maxVisibleRowCount can become negative if the headers and footers * start to overlap. This is a crazy situation, but Vaadin blinks * the components a lot, so it's feasible. */ return Math.max(0, maxVisibleRowCount); } @Override protected void paintRemoveRows(final int index, final int numberOfRows) { if (numberOfRows == 0) { return; } /* * NOTE: this method handles and manipulates logical, visual, and * physical indexes a lot. If you don't remember what those mean and * how they relate to each other, see the top of this class for * Maintenance Notes. * * At the beginning of this method the logical index of the data * provider has already been updated to include the new rows, but * visual and physical indexes have not, nor has the spacer indexing * been updated, and the topRowLogicalIndex may be out of date as * well. */ // logical index of the first old row, also the difference between // logical index and visual index before any rows have been removed final int oldTopRowLogicalIndex = getTopRowLogicalIndex(); // length of the visual range before anything gets removed final int oldVisualRangeLength = visualRowOrder.size(); // logical range of the removed rows final Range removedRowsLogicalRange = Range.withLength(index, numberOfRows); // check which parts of the removed range fall within or beyond the // visual range final Range[] partitions = removedRowsLogicalRange .partitionWith(Range.withLength(oldTopRowLogicalIndex, oldVisualRangeLength)); final Range removedLogicalAbove = partitions[0]; final Range removedLogicalBelow = partitions[2]; final Range removedLogicalWithin = partitions[1]; if (removedLogicalBelow.length() == numberOfRows) { /* * Rows were removed entirely from below the visual range. No * rows to recycle or scroll position to adjust, just need to * recalculate scrollbar height. No need to touch the spacer * indexing or the physical index. */ scroller.recalculateScrollbarsForVirtualViewport(); // Visual range contents remain the same, no need to fire a // RowVisibilityChangeEvent. } else if (removedLogicalAbove.length() == numberOfRows) { /* * Rows were removed entirely from above the visual range. No * rows to recycle, just need to update the spacer indexing and * the content positions. No need to touch the physical index. */ // update the logical indexes of remaining spacers spacerContainer.updateSpacerIndexesForRowAndAfter( oldTopRowLogicalIndex, oldTopRowLogicalIndex + oldVisualRangeLength, -numberOfRows); // default height of a single row final double defaultRowHeight = getDefaultRowHeight(); // how much viewport, rows, and spacers should be shifted based // on the removed rows, assume there were no spacers to remove final double yDelta = numberOfRows * defaultRowHeight; // shift everything up moveViewportAndContent(null, -yDelta, -yDelta, -yDelta); // update the top row logical index according to any removed // rows updateTopRowLogicalIndex(-numberOfRows); // update scrollbar scroller.recalculateScrollbarsForVirtualViewport(); // Visual range contents remain the same, no need to fire a // RowVisibilityChangeEvent. } else { /* * Rows are being removed at least partially from within the * visual range. This is where things get tricky. We might have * to scroll up or down or nowhere at all, depending on the * situation. */ // Visual range contents changed, RowVisibilityChangeEvent will // be triggered within this method paintRemoveRowsWithinVisualRange(index, numberOfRows, oldTopRowLogicalIndex, oldVisualRangeLength, removedLogicalAbove.length(), removedLogicalWithin); } } /** * Row removal handling for {@link #paintRemoveRows(int, int)} when the * removed range intersects the visual range at least partially. *

* NOTE: This method should not be called directly from anywhere else. * * @param index * @param numberOfRows * @param oldTopRowLogicalIndex * @param oldVisualRangeLength * @param removedAboveLength * @param removedLogicalWithin */ private void paintRemoveRowsWithinVisualRange(int index, int numberOfRows, int oldTopRowLogicalIndex, int oldVisualRangeLength, int removedAboveLength, Range removedLogicalWithin) { /* * Calculating where the visual range should start after the * removals is not entirely trivial. * * Initially, any rows removed from within the visual range won't * affect the top index, even if they are removed from the * beginning, as the rows are also removed from the logical index. * Likewise we don't need to care about rows removed from below the * visual range. On the other hand, any rows removed from above the * visual range do shift the index down. * * However, in all of these cases, if there aren't enough rows below * the visual range to replace the content removed from within the * visual range, more rows need to be brought in from above the old * visual range in turn. This shifts the index down even further. */ // scroll position before any rows or spacers are removed double scrollTop = getScrollTop(); Range removedVisualWithin = convertToVisual(removedLogicalWithin); int remainingVisualRangeRowCount = visualRowOrder.size() - removedVisualWithin.length(); int newTopRowLogicalIndex = oldTopRowLogicalIndex - removedAboveLength; int rowsToIncludeFromBelow = Math.min( getRowCount() - newTopRowLogicalIndex - remainingVisualRangeRowCount, removedLogicalWithin.length()); int rowsToIncludeFromAbove = removedLogicalWithin.length() - rowsToIncludeFromBelow; int rowsToRemoveFromDom = 0; if (rowsToIncludeFromAbove > 0) { // don't try to bring in more rows than exist, it's possible // to remove enough rows that visual range won't be full // anymore rowsToRemoveFromDom = Math .max(rowsToIncludeFromAbove - newTopRowLogicalIndex, 0); rowsToIncludeFromAbove -= rowsToRemoveFromDom; newTopRowLogicalIndex -= rowsToIncludeFromAbove; } int visualIndexToRemove = Math.max(index - oldTopRowLogicalIndex, 0); // remove extra dom rows and their spacers if any double removedFromDomSpacerHeights = 0d; if (rowsToRemoveFromDom > 0) { for (int i = 0; i < rowsToRemoveFromDom; ++i) { TableRowElement tr = visualRowOrder .remove(visualIndexToRemove); // logical index of this row before anything got removed int logicalRowIndex = oldTopRowLogicalIndex + visualIndexToRemove + i; double spacerHeight = spacerContainer .getSpacerHeight(logicalRowIndex); removedFromDomSpacerHeights += spacerHeight; spacerContainer.removeSpacer(logicalRowIndex); paintRemoveRow(tr, removedVisualWithin.getStart()); removeRowPosition(tr); } // update the associated row indexes for remaining spacers, // even for those rows that are going to get recycled spacerContainer.updateSpacerIndexesForRowAndAfter( oldTopRowLogicalIndex + visualIndexToRemove + rowsToRemoveFromDom, oldTopRowLogicalIndex + oldVisualRangeLength, -rowsToRemoveFromDom); } // add new content from below visual range, if there is any if (rowsToIncludeFromBelow > 0) { // removed rows are recycled to just below the old visual // range, calculate the logical index of the insertion // point that is just below the existing rows, taking into // account that the indexing has changed with the removal int firstBelow = newTopRowLogicalIndex + rowsToIncludeFromAbove + remainingVisualRangeRowCount; moveAndUpdateEscalatorRows( Range.withLength(visualIndexToRemove, rowsToIncludeFromBelow), visualRowOrder.size(), firstBelow); } // add new content from above visual range, if there is any // -- this is left last because most of the time it isn't even // needed if (rowsToIncludeFromAbove > 0) { moveAndUpdateEscalatorRows( Range.withLength(visualIndexToRemove, rowsToIncludeFromAbove), 0, newTopRowLogicalIndex); } // recycling updates all relevant row and spacer positions but // if we only removed DOM rows and didn't recycle any we still // need to shift up the rows below the removal point if (rowsToIncludeFromAbove <= 0 && rowsToIncludeFromBelow <= 0) { // update the positions for the rows and spacers below the // removed ones, assume there is no need to update scroll // position since the final check adjusts that if needed double yDelta = numberOfRows * getDefaultRowHeight() + removedFromDomSpacerHeights; moveViewportAndContent( newTopRowLogicalIndex + visualIndexToRemove, -yDelta, -yDelta, 0); } setTopRowLogicalIndex(newTopRowLogicalIndex); scroller.recalculateScrollbarsForVirtualViewport(); // calling this method also triggers adding new spacers to the // recycled rows, if any are needed fireRowVisibilityChangeEvent(); // populating the spacers might take a while, delay calculations // or the viewport might get adjusted too high Scheduler.get().scheduleFinally(() -> { // make sure there isn't a gap at the bottom after removal // and adjust the viewport if there is // FIXME: this should be doable with // adjustScrollPositionIfNeeded() but it uses current // scrollTop, which may have ended in wrong position and // results in assuming too big gap and consequently // scrolling up too much double extraSpaceAtBottom = scrollTop + getHeightOfSection() - getRowTop(getTopRowLogicalIndex() + visualRowOrder.size()); if (extraSpaceAtBottom > 0 && scrollTop > 0) { // we need to move the viewport up to adjust, while the // rows and spacers can remain where they are double yDeltaScroll = Math.min(extraSpaceAtBottom, scrollTop); moveViewportAndContent(null, 0, 0, -yDeltaScroll); } }); // update physical index sortDomElements(); } @Override public int getLogicalRowIndex(final TableRowElement tr) { assert tr .getParentNode() == root : "The given element isn't a row element in the body"; int internalIndex = visualRowOrder.indexOf(tr); return getTopRowLogicalIndex() + internalIndex; } @Override protected void recalculateSectionHeight() { // NOOP for body, since it doesn't make any sense. } /** * Adjusts the row index and number to be relevant for the current * virtual viewport. *

* It converts a logical range of rows index to the matching visual * range, truncating the resulting range with the viewport. *

*

* * @return a logical range converted to a visual range, truncated to the * current viewport. The first visual row has the index 0. */ private Range convertToVisual(final Range logicalRange) { if (logicalRange.isEmpty()) { return logicalRange; } else if (visualRowOrder.isEmpty()) { // empty range return Range.withLength(0, 0); } final int currentTopRowIndex = getTopRowLogicalIndex(); final Range[] partitions = logicalRange .partitionWith(getVisibleRowRange()); final Range insideRange = partitions[1]; return insideRange.offsetBy(-currentTopRowIndex); } @Override protected String getCellElementTagName() { return "td"; } @Override protected double getHeightOfSection() { final int tableHeight = tableWrapper.getOffsetHeight(); final double footerHeight = footer.getHeightOfSection(); final double headerHeight = header.getHeightOfSection(); double heightOfSection = tableHeight - footerHeight - headerHeight; return Math.max(0, heightOfSection); } @Override protected void refreshCells(Range logicalRowRange, Range colRange) { Profiler.enter("Escalator.BodyRowContainer.refreshRows"); final Range visualRange = convertToVisual(logicalRowRange); if (!visualRange.isEmpty()) { final int firstLogicalRowIndex = getLogicalRowIndex( visualRowOrder.getFirst()); for (int rowNumber = visualRange .getStart(); rowNumber < visualRange .getEnd(); rowNumber++) { refreshRow(visualRowOrder.get(rowNumber), firstLogicalRowIndex + rowNumber, colRange); } } Profiler.leave("Escalator.BodyRowContainer.refreshRows"); } @Override protected TableRowElement getTrByVisualIndex(final int index) throws IndexOutOfBoundsException { if (index >= 0 && index < visualRowOrder.size()) { return visualRowOrder.get(index); } else { throw new IndexOutOfBoundsException( "No such visual index: " + index); } } @Override public TableRowElement getRowElement(int index) { if (index < 0 || index >= getRowCount()) { throw new IndexOutOfBoundsException( "No such logical index: " + index); } int visualIndex = index - getLogicalRowIndex(visualRowOrder.getFirst()); if (visualIndex >= 0 && visualIndex < visualRowOrder.size()) { return super.getRowElement(visualIndex); } else { throw new IllegalStateException("Row with logical index " + index + " is currently not available in the DOM"); } } private void setBodyScrollPosition(final double scrollLeft, final double scrollTop) { tBodyScrollLeft = scrollLeft; tBodyScrollTop = scrollTop; position.set(bodyElem, -tBodyScrollLeft, -tBodyScrollTop); position.set(spacerDecoContainer, 0, -tBodyScrollTop); } /** * Make sure that there is a correct amount of escalator rows: Add more * if needed, or remove any superfluous ones. *

* This method should be called when e.g. the height of the Escalator * changes. *

* Note: This method will make sure that the escalator rows are * placed in the proper places. By default new rows are added below, but * if the content is scrolled down, the rows are populated on top * instead. */ public void verifyEscalatorCount() { /* * This method indeed has a smell very similar to paintRemoveRows * and paintInsertRows. * * Unfortunately, the code of those can't trivially be shared, since * there are some slight differences in the respective * responsibilities. The "paint" methods fake the addition and * removal of rows, and make sure to either push existing data out * of view, or draw new data into view. Only in some special cases * will the DOM element count change. * * This method, however, has the explicit responsibility to verify * that when "something" happens, we still have the correct amount * of escalator rows in the DOM, and if not, we make sure to modify * that count. Only in some special cases do we need to take into * account other things than simply modifying the DOM element count. */ Profiler.enter("Escalator.BodyRowContainer.verifyEscalatorCount"); if (!isAttached()) { return; } int oldTopRowLogicalIndex = getTopRowLogicalIndex(); int oldVisualRangeLength = visualRowOrder.size(); final int maxVisibleRowCount = getMaxVisibleRowCount(); final int neededEscalatorRows = Math.min(maxVisibleRowCount, body.getRowCount()); final int rowDiff = neededEscalatorRows - oldVisualRangeLength; if (rowDiff > 0) { // more rows are needed // calculate the indexes for adding rows below the last row of // the visual range final int visualTargetIndex = oldVisualRangeLength; final int logicalTargetIndex; if (!visualRowOrder.isEmpty()) { logicalTargetIndex = oldTopRowLogicalIndex + visualTargetIndex; } else { logicalTargetIndex = 0; } // prioritise adding to the bottom so that there's less chance // for a gap if a details row is later closed (e.g. by user) final int addToBottom = Math.min(rowDiff, getRowCount() - logicalTargetIndex); final int addToTop = rowDiff - addToBottom; if (addToTop > 0) { fillAndPopulateEscalatorRowsIfNeeded(0, oldTopRowLogicalIndex - addToTop, addToTop); updateTopRowLogicalIndex(-addToTop); } if (addToBottom > 0) { fillAndPopulateEscalatorRowsIfNeeded(visualTargetIndex, logicalTargetIndex, addToBottom); } } else if (rowDiff < 0) { // rows need to be removed // prioritise removing rows from above the viewport as they are // less likely to be needed in a hurry -- the rows below are // more likely to slide into view when spacer contents are // updated // top of visible area before any rows are actually added double scrollTop = getScrollTop(); // visual index of the first actually visible row, including // spacer int oldFirstVisibleVisualIndex = -1; ListIterator iter = visualRowOrder .listIterator(0); for (int i = 0; i < visualRowOrder.size(); ++i) { if (positions.getTop(iter.next()) > scrollTop) { break; } oldFirstVisibleVisualIndex = i; } int rowsToRemoveFromAbove = Math.max(0, Math .min(Math.abs(rowDiff), oldFirstVisibleVisualIndex)); boolean spacersRemovedFromAbove = false; if (rowsToRemoveFromAbove > 0) { double initialSpacerHeightSum = spacerContainer .getSpacerHeightsSum(); iter = visualRowOrder.listIterator(0); for (int i = 0; i < rowsToRemoveFromAbove; ++i) { final Element first = iter.next(); first.removeFromParent(); iter.remove(); spacerContainer.removeSpacer(oldTopRowLogicalIndex + i); } spacersRemovedFromAbove = initialSpacerHeightSum != spacerContainer .getSpacerHeightsSum(); } // if there weren't enough rows above, remove the rest from // below int rowsToRemoveFromBelow = Math.abs(rowDiff) - rowsToRemoveFromAbove; if (rowsToRemoveFromBelow > 0) { iter = visualRowOrder.listIterator(visualRowOrder.size()); for (int i = 1; i <= rowsToRemoveFromBelow; ++i) { final Element last = iter.previous(); last.removeFromParent(); iter.remove(); spacerContainer.removeSpacer(oldTopRowLogicalIndex + oldVisualRangeLength - i); } } updateTopRowLogicalIndex(rowsToRemoveFromAbove); if (spacersRemovedFromAbove) { updateRowPositions(oldTopRowLogicalIndex, 0, visualRowOrder.size()); } // removing rows might cause a gap at the bottom adjustScrollPositionIfNeeded(); } if (rowDiff != 0) { scroller.recalculateScrollbarsForVirtualViewport(); fireRowVisibilityChangeEvent(); } Profiler.leave("Escalator.BodyRowContainer.verifyEscalatorCount"); } @Override protected void reapplyDefaultRowHeights() { if (visualRowOrder.isEmpty()) { return; } Profiler.enter( "Escalator.BodyRowContainer.reapplyDefaultRowHeights"); double spacerHeightsAboveViewport = spacerContainer .getSpacerHeightsSumUntilPx( verticalScrollbar.getScrollPos()); double allSpacerHeights = spacerContainer.getSpacerHeightsSum(); /* step 1: resize and reposition rows */ // there should be no spacers above the visual range double spacerHeights = 0; for (int i = 0; i < visualRowOrder.size(); i++) { TableRowElement tr = visualRowOrder.get(i); reapplyRowHeight(tr, getDefaultRowHeight()); final int logicalIndex = getTopRowLogicalIndex() + i; double y = logicalIndex * getDefaultRowHeight() + spacerHeights; setRowPosition(tr, 0, y); SpacerContainer.SpacerImpl spacer = spacerContainer .getSpacer(logicalIndex); if (spacer != null) { spacer.setPosition(0, y + getDefaultRowHeight()); spacerHeights += spacer.getHeight(); } } /* * step 2: move scrollbar so that it corresponds to its previous * place */ // scrollRatio has to be calculated without spacers for it to be // comparable between different row heights double scrollRatio = (verticalScrollbar.getScrollPos() - spacerHeightsAboveViewport) / (verticalScrollbar.getScrollSize() - allSpacerHeights); scroller.recalculateScrollbarsForVirtualViewport(); // spacer heights have to be added back for setting new scrollPos verticalScrollbar.setScrollPos( (int) ((getDefaultRowHeight() * getRowCount() * scrollRatio) + spacerHeightsAboveViewport)); setBodyScrollPosition(horizontalScrollbar.getScrollPos(), verticalScrollbar.getScrollPos()); scroller.onScroll(); /* * step 3: make sure we have the correct amount of escalator rows. */ verifyEscalatorCount(); Profiler.leave( "Escalator.BodyRowContainer.reapplyDefaultRowHeights"); } /** * Sorts the rows in the DOM to correspond to the visual order. * * @see #visualRowOrder */ private void sortDomElements() { final String profilingName = "Escalator.BodyRowContainer.sortDomElements"; Profiler.enter(profilingName); /* * Focus is lost from an element if that DOM element is (or any of * its parents are) removed from the document. Therefore, we sort * everything around that row instead. */ final TableRowElement focusedRow = getRowWithFocus(); if (focusedRow != null) { assert focusedRow .getParentElement() == root : "Trying to sort around a row that doesn't exist in body"; assert visualRowOrder.contains(focusedRow) || body.spacerContainer.isSpacer( focusedRow) : "Trying to sort around a row that doesn't exist in visualRowOrder or is not a spacer."; } /* * Two cases handled simultaneously: * * 1) No focus on rows. We iterate visualRowOrder backwards, and * take the respective element in the DOM, and place it as the first * child in the body element. Then we take the next-to-last from * visualRowOrder, and put that first, pushing the previous row as * the second child. And so on... * * 2) Focus on some row within Escalator body. Again, we iterate * visualRowOrder backwards. This time, we use the focused row as a * pivot: Instead of placing rows from the bottom of visualRowOrder * and placing it first, we place it underneath the focused row. * Once we hit the focused row, we don't move it (to not reset * focus) but change sorting mode. After that, we place all rows as * the first child. */ List orderedBodyRows = new ArrayList<>( visualRowOrder); Map spacers = body.spacerContainer .getSpacers(); /* * Start at -1 to include a spacer that is rendered above the * viewport, but its parent row is still not shown */ for (int i = -1; i < visualRowOrder.size(); i++) { SpacerContainer.SpacerImpl spacer = spacers .remove(Integer.valueOf(getTopRowLogicalIndex() + i)); if (spacer != null) { orderedBodyRows.add(i + 1, spacer.getRootElement()); spacer.show(); } } /* * At this point, invisible spacers aren't reordered, so their * position in the DOM will remain undefined. */ // If a spacer was not reordered, it means that it's out of visual // range. This should never happen with default Grid implementations // but it's possible on an extended Escalator. for (SpacerContainer.SpacerImpl unmovedSpacer : spacers.values()) { unmovedSpacer.hide(); } /* * If we have a focused row, start in the mode where we put * everything underneath that row. Otherwise, all rows are placed as * first child. */ boolean insertFirst = (focusedRow == null); final ListIterator i = orderedBodyRows .listIterator(orderedBodyRows.size()); while (i.hasPrevious()) { TableRowElement tr = i.previous(); if (tr == focusedRow) { insertFirst = true; } else if (insertFirst) { // remove row explicitly to work around an IE11 bug (#9850) if (BrowserInfo.get().isIE11() && tr.equals(root.getFirstChildElement())) { root.removeChild(tr); } root.insertFirst(tr); } else { root.insertAfter(tr, focusedRow); } } Profiler.leave(profilingName); } /** * Get the {@literal } row that contains (or has) focus. * * @return The {@literal } row that contains a focused DOM * element, or null if focus is outside of a body * row. */ private TableRowElement getRowWithFocus() { TableRowElement rowContainingFocus = null; final Element focusedElement = WidgetUtil.getFocusedElement(); if (focusedElement != null && root.isOrHasChild(focusedElement)) { Element e = focusedElement; while (e != null && e != root) { /* * You never know if there's several tables embedded in a * cell... We'll take the deepest one. */ if (TableRowElement.is(e)) { rowContainingFocus = TableRowElement.as(e); } e = e.getParentElement(); } } return rowContainingFocus; } /** * Returns the cell object which contains information about the cell or * spacer the element is in. As an implementation detail each spacer is * a row with one cell, but they are stored in their own container and * share the indexing with the regular rows. * * @param element * The element to get the cell for. If element is not present * in row or spacer container then null is * returned. * * @return the cell reference of the element, or null if * element is not present in the {@link RowContainer} or the * {@link SpacerContainer}. */ @Override public Cell getCell(Element element) { Cell cell = super.getCell(element); if (cell == null) { return null; } // Convert DOM coordinates to logical coordinates for rows TableRowElement rowElement = (TableRowElement) cell.getElement() .getParentElement(); if (!visualRowOrder.contains(rowElement)) { for (Entry entry : spacerContainer .getSpacers().entrySet()) { if (rowElement.equals(entry.getValue().getRootElement())) { return new Cell(entry.getKey(), cell.getColumn(), cell.getElement()); } } return null; } return new Cell(getLogicalRowIndex(rowElement), cell.getColumn(), cell.getElement()); } @Override public void setSpacer(int rowIndex, double height) throws IllegalArgumentException { spacerContainer.setSpacer(rowIndex, height); } @Override public boolean spacerExists(int rowIndex) { return spacerContainer.spacerExists(rowIndex); } @Override public void setSpacerUpdater(SpacerUpdater spacerUpdater) throws IllegalArgumentException { spacerContainer.setSpacerUpdater(spacerUpdater); } @Override public SpacerUpdater getSpacerUpdater() { return spacerContainer.getSpacerUpdater(); } /** * Calculates the expected top position of a row at a logical * index, regardless if there is one there currently or not. *

* This method relies on fixed row height (by * {@link #getDefaultRowHeight()}) and can only take into account * spacers that are within visual range. Any scrolling might invalidate * these results, so this method shouldn't be used to estimate scroll * positions. * * @param logicalIndex * the logical index of the row for which to calculate the * top position * @return the position where the row should currently be, were it to * exist * @see #getRowTop(TableRowElement) */ private double getRowTop(int logicalIndex) { double top = spacerContainer .getSpacerHeightsSumUntilIndex(logicalIndex); return top + (logicalIndex * getDefaultRowHeight()); } public void shiftRowPositions(int row, double diff) { for (TableRowElement tr : getVisibleRowsAfter(row)) { setRowPosition(tr, 0, getRowTop(tr) + diff); } } private List getVisibleRowsAfter(int logicalRow) { Range visibleRowLogicalRange = getVisibleRowRange(); boolean allRowsAreInView = logicalRow < visibleRowLogicalRange .getStart(); boolean noRowsAreInView = logicalRow >= visibleRowLogicalRange .getEnd() - 1; if (allRowsAreInView) { return Collections.unmodifiableList(visualRowOrder); } else if (noRowsAreInView) { return Collections.emptyList(); } else { int fromIndex = (logicalRow - visibleRowLogicalRange.getStart()) + 1; int toIndex = visibleRowLogicalRange.length(); List sublist = visualRowOrder .subList(fromIndex, toIndex); return Collections.unmodifiableList(sublist); } } @Override public int getDomRowCount() { return root.getChildCount() - spacerContainer.getSpacersInDom().size(); } @Override protected boolean rowCanBeFrozen(TableRowElement tr) { return visualRowOrder.contains(tr); } void reapplySpacerWidths() { spacerContainer.reapplySpacerWidths(); } void scrollToRowSpacerOrBoth(int targetRowIndex, ScrollDestination destination, double padding, ScrollType scrollType) { if (!ensureScrollingAllowed()) { return; } validateScrollDestination(destination, (int) padding); // ignore the special case of -1 index spacer from the row index // validation if (!(targetRowIndex == -1 && !ScrollType.ROW.equals(scrollType))) { // throws an IndexOutOfBoundsException if not valid verifyValidRowIndex(targetRowIndex); } int oldTopRowLogicalIndex = getTopRowLogicalIndex(); int visualRangeLength = visualRowOrder.size(); int paddingInRows = 0; if (!WidgetUtil.pixelValuesEqual(padding, 0d)) { paddingInRows = (int) Math .ceil(Double.valueOf(padding) / getDefaultRowHeight()); } // calculate the largest index necessary to include at least // partially below the top of the viewport and the smallest index // necessary to include at least partially above the bottom of the // viewport (target row itself might not be if padding is negative) int firstVisibleIndexIfScrollingUp = targetRowIndex - paddingInRows; int lastVisibleIndexIfScrollingDown = targetRowIndex + paddingInRows; int oldFirstBelowIndex = oldTopRowLogicalIndex + visualRangeLength; int newTopRowLogicalIndex; int logicalTargetIndex; switch (destination) { case ANY: // scroll as little as possible, take into account that there // needs to be a buffer row at both ends if there is room for // one boolean newRowsNeededAbove = (firstVisibleIndexIfScrollingUp < oldTopRowLogicalIndex) || (firstVisibleIndexIfScrollingUp == oldTopRowLogicalIndex && targetRowIndex > 0); boolean rowsNeededBelow = (lastVisibleIndexIfScrollingDown >= oldFirstBelowIndex) || ((lastVisibleIndexIfScrollingDown == oldFirstBelowIndex - 1) && (oldFirstBelowIndex < getRowCount())); if (newRowsNeededAbove) { // scroll up, add buffer row if it fits logicalTargetIndex = Math .max(firstVisibleIndexIfScrollingUp - 1, 0); newTopRowLogicalIndex = logicalTargetIndex; } else if (rowsNeededBelow) { // scroll down, add buffer row if it fits newTopRowLogicalIndex = Math.min( lastVisibleIndexIfScrollingDown + 1, getRowCount() - 1) - visualRangeLength + 1; if (newTopRowLogicalIndex - oldTopRowLogicalIndex < visualRangeLength) { // partial recycling, target index at the end of // current range logicalTargetIndex = oldFirstBelowIndex; } else { // full recycling, target index the same as the new // top row index logicalTargetIndex = newTopRowLogicalIndex; } } else { // no need to recycle rows but viewport might need // adjusting regardless logicalTargetIndex = -1; newTopRowLogicalIndex = oldTopRowLogicalIndex; } break; case END: // target row at the bottom of the viewport newTopRowLogicalIndex = lastVisibleIndexIfScrollingDown + 1 - visualRangeLength + 1; newTopRowLogicalIndex = ensureTopRowLogicalIndexSanity( newTopRowLogicalIndex); if ((newTopRowLogicalIndex > oldTopRowLogicalIndex) && (newTopRowLogicalIndex - oldTopRowLogicalIndex < visualRangeLength)) { // partial recycling, target index at the end of // current range logicalTargetIndex = oldFirstBelowIndex; } else { // full recycling, target index the same as the new // top row index logicalTargetIndex = newTopRowLogicalIndex; } break; case MIDDLE: // target row at the middle of the viewport, padding has to be // zero or we never would have reached this far newTopRowLogicalIndex = targetRowIndex - visualRangeLength / 2; newTopRowLogicalIndex = ensureTopRowLogicalIndexSanity( newTopRowLogicalIndex); if (newTopRowLogicalIndex < oldTopRowLogicalIndex) { logicalTargetIndex = newTopRowLogicalIndex; } else if (newTopRowLogicalIndex > oldTopRowLogicalIndex) { if (newTopRowLogicalIndex - oldTopRowLogicalIndex < visualRangeLength) { // partial recycling, target index at the end of // current range logicalTargetIndex = oldFirstBelowIndex; } else { // full recycling, target index the same as the new // top row index logicalTargetIndex = newTopRowLogicalIndex; } } else { logicalTargetIndex = -1; } break; case START: // target row at the top of the viewport, include buffer // row if there is room for one newTopRowLogicalIndex = firstVisibleIndexIfScrollingUp - 1; newTopRowLogicalIndex = ensureTopRowLogicalIndexSanity( newTopRowLogicalIndex); if (getVisibleRowRange().contains(newTopRowLogicalIndex)) { logicalTargetIndex = oldTopRowLogicalIndex + visualRangeLength; } else { logicalTargetIndex = newTopRowLogicalIndex; } break; default: String msg = "Internal: Unsupported ScrollDestination: "; throw new IllegalArgumentException(msg + destination.name()); } // adjust visual range if necessary if (newTopRowLogicalIndex < oldTopRowLogicalIndex) { adjustVisualRangeUpForScrollToRowSpacerOrBoth( oldTopRowLogicalIndex, visualRangeLength, logicalTargetIndex); } else if (newTopRowLogicalIndex > oldTopRowLogicalIndex) { adjustVisualRangeDownForScrollToRowSpacerOrBoth( oldTopRowLogicalIndex, visualRangeLength, newTopRowLogicalIndex, logicalTargetIndex); } boolean rowsWereMoved = newTopRowLogicalIndex != oldTopRowLogicalIndex; // update scroll position if necessary adjustScrollPositionForScrollToRowSpacerOrBoth(targetRowIndex, destination, padding, scrollType); if (rowsWereMoved) { fireRowVisibilityChangeEvent(); // schedule updating of the physical indexes domSorter.reschedule(); } } /** * Modifies the proposed top row logical index to fit within the logical * range and to not leave gaps if it is avoidable. * * @param proposedTopRowLogicalIndex * @return an adjusted index, or the original if no changes were * necessary */ private int ensureTopRowLogicalIndexSanity( int proposedTopRowLogicalIndex) { int newTopRowLogicalIndex = Math.max(proposedTopRowLogicalIndex, 0); int visualRangeLength = visualRowOrder.size(); if (newTopRowLogicalIndex + visualRangeLength > getRowCount()) { newTopRowLogicalIndex = getRowCount() - visualRangeLength; } return newTopRowLogicalIndex; } /** * Checks that scrolling is allowed and resets the scroll position if * it's not. * * @return {@code true} if scrolling is allowed, {@code false} otherwise */ private boolean ensureScrollingAllowed() { if (isScrollLocked(Direction.VERTICAL)) { // no scrolling can happen if (getScrollTop() != tBodyScrollTop) { setBodyScrollPosition(tBodyScrollLeft, getScrollTop()); } return false; } return true; } /** * Adjusts visual range up for * {@link #scrollToRowSpacerOrBoth(int, ScrollDestination, double, boolean, boolean)}, * reuse at your own peril. * * @param oldTopRowLogicalIndex * @param visualRangeLength * @param logicalTargetIndex */ private void adjustVisualRangeUpForScrollToRowSpacerOrBoth( int oldTopRowLogicalIndex, int visualRangeLength, int logicalTargetIndex) { // recycle at most the visual range's worth of rows to fill // the gap between the new visualTargetIndex and the existing // rows int rowsToRecycle = Math.min( oldTopRowLogicalIndex - logicalTargetIndex, visualRangeLength); // recycle from the end to the beginning moveAndUpdateEscalatorRows( Range.withLength(visualRangeLength - rowsToRecycle, rowsToRecycle), 0, logicalTargetIndex); // update the index setTopRowLogicalIndex(logicalTargetIndex); } /** * Adjusts visual range down for * {@link #scrollToRowSpacerOrBoth(int, ScrollDestination, double, boolean, boolean)}, * reuse at your own peril. * * @param oldTopRowLogicalIndex * @param visualRangeLength * @param newTopRowLogicalIndex * @param logicalTargetIndex */ private void adjustVisualRangeDownForScrollToRowSpacerOrBoth( int oldTopRowLogicalIndex, int visualRangeLength, int newTopRowLogicalIndex, int logicalTargetIndex) { // recycle at most the visual range's worth of rows to fill // the gap between the new visualTargetIndex and the existing // rows int rowsToRecycle; if (newTopRowLogicalIndex - oldTopRowLogicalIndex >= visualRangeLength) { // full recycling rowsToRecycle = visualRangeLength; } else { // partial recycling rowsToRecycle = newTopRowLogicalIndex - oldTopRowLogicalIndex; } // recycle from the beginning to the end moveAndUpdateEscalatorRows(Range.withLength(0, rowsToRecycle), visualRangeLength, logicalTargetIndex); // update the index setTopRowLogicalIndex(newTopRowLogicalIndex); } /** * Adjusts scroll position for * {@link #scrollToRowSpacerOrBoth(int, ScrollDestination, double, boolean, boolean)}, * reuse at your own peril. * * @param targetRowIndex * @param destination * @param padding * @param scrollType */ private void adjustScrollPositionForScrollToRowSpacerOrBoth( int targetRowIndex, ScrollDestination destination, double padding, ScrollType scrollType) { /* * attempting to scroll above first row or below last row would get * automatically corrected later but that causes unnecessary * calculations, so try not to overshoot */ double sectionHeight = getHeightOfSection(); double rowTop = getRowTop(targetRowIndex); double spacerHeight = spacerContainer .getSpacerHeight(targetRowIndex); double scrollTop; switch (destination) { case ANY: if (!ScrollType.SPACER.equals(scrollType) && Math.max(rowTop - padding, 0) < getScrollTop()) { // within visual range but row top above the viewport or not // enough padding, shift a little scrollTop = Math.max(rowTop - padding, 0); } else if (ScrollType.SPACER.equals(scrollType) && Math.max(rowTop + getDefaultRowHeight() - padding, 0) < getScrollTop()) { // within visual range but spacer top above the viewport or // not enough padding, shift a little scrollTop = Math .max(rowTop + getDefaultRowHeight() - padding, 0); } else if (ScrollType.ROW.equals(scrollType) && rowTop + getDefaultRowHeight() + padding > getScrollTop() + sectionHeight) { // within visual range but end of row below the viewport // or not enough padding, shift a little scrollTop = rowTop + getDefaultRowHeight() - sectionHeight + padding; // ensure that we don't overshoot beyond bottom scrollTop = Math.min(scrollTop, getRowTop(getRowCount() - 1) + getDefaultRowHeight() + spacerContainer .getSpacerHeight(getRowCount() - 1) - sectionHeight); // if padding is set we want to overshoot or undershoot, // otherwise make sure the top of the row is in view if (padding == 0) { scrollTop = Math.min(scrollTop, rowTop); } } else if (rowTop + getDefaultRowHeight() + spacerHeight + padding > getScrollTop() + sectionHeight) { // within visual range but end of spacer below the viewport // or not enough padding, shift a little scrollTop = rowTop + getDefaultRowHeight() + spacerHeight - sectionHeight + padding; // ensure that we don't overshoot beyond bottom scrollTop = Math.min(scrollTop, getRowTop(getRowCount()) - sectionHeight); // if padding is set we want to overshoot or undershoot, // otherwise make sure the top of the row or spacer is // in view if (padding == 0) { if (ScrollType.SPACER.equals(scrollType)) { scrollTop = Math.min(scrollTop, rowTop + getDefaultRowHeight()); } else { scrollTop = Math.min(scrollTop, rowTop); } } } else { // we are fine where we are scrollTop = getScrollTop(); } break; case END: if (ScrollType.ROW.equals(scrollType) && rowTop + getDefaultRowHeight() + padding != getScrollTop() + sectionHeight) { // row should be at the bottom of the viewport scrollTop = rowTop + getDefaultRowHeight() - sectionHeight + padding; } else if (rowTop + getDefaultRowHeight() + spacerHeight + padding != getScrollTop() + sectionHeight) { // spacer should be at the bottom of the viewport scrollTop = rowTop + getDefaultRowHeight() + spacerHeight - sectionHeight + padding; } else { // we are fine where we are scrollTop = getScrollTop(); } break; case MIDDLE: double center; if (ScrollType.ROW.equals(scrollType)) { // center the row itself center = rowTop + (getDefaultRowHeight() / 2.0); } else if (ScrollType.ROW_AND_SPACER.equals(scrollType)) { // center both center = rowTop + ((getDefaultRowHeight() + spacerHeight) / 2.0); } else { // center the spacer center = rowTop + getDefaultRowHeight() + (spacerHeight / 2.0); } scrollTop = center - Math.ceil(sectionHeight / 2.0); break; case START: if (!ScrollType.SPACER.equals(scrollType) && Math.max(rowTop - padding, 0) != getScrollTop()) { // row should be at the top of the viewport scrollTop = Math.max(rowTop - padding, 0); } else if (ScrollType.SPACER.equals(scrollType) && Math.max(rowTop + getDefaultRowHeight() - padding, 0) != getScrollTop()) { // spacer should be at the top of the viewport scrollTop = Math .max(rowTop + getDefaultRowHeight() - padding, 0); } else { scrollTop = getScrollTop(); } break; default: scrollTop = getScrollTop(); } // ensure that we don't overshoot beyond bottom scrollTop = Math.min(scrollTop, getRowTop(getRowCount()) - sectionHeight); // ensure that we don't overshoot beyond top scrollTop = Math.max(0, scrollTop); if (scrollTop != getScrollTop()) { setScrollTop(scrollTop); setBodyScrollPosition(tBodyScrollLeft, scrollTop); } } @Override public void setNewRowCallback( Consumer> callback) { newEscalatorRowCallback = callback; } } private class ColumnConfigurationImpl implements ColumnConfiguration { public class Column { public static final double DEFAULT_COLUMN_WIDTH_PX = 100; private double definedWidth = -1; private double calculatedWidth = DEFAULT_COLUMN_WIDTH_PX; private boolean measuringRequested = false; public void setWidth(double px) { Profiler.enter( "Escalator.ColumnConfigurationImpl.Column.setWidth"); definedWidth = px; if (px < 0) { if (isAttached()) { calculateWidth(); } else { /* * the column's width is calculated at Escalator.onLoad * via measureAndSetWidthIfNeeded! */ measuringRequested = true; } } else { calculatedWidth = px; } Profiler.leave( "Escalator.ColumnConfigurationImpl.Column.setWidth"); } public double getDefinedWidth() { return definedWidth; } /** * Returns the actual width in the DOM. * * @return the width in pixels in the DOM. Returns -1 if the column * needs measuring, but has not been yet measured */ public double getCalculatedWidth() { /* * This might return an untrue value (e.g. during init/onload), * since we haven't had a proper chance to actually calculate * widths yet. * * This is fixed during Escalator.onLoad, by the call to * "measureAndSetWidthIfNeeded", which fixes "everything". */ if (!measuringRequested) { return calculatedWidth; } else { return -1; } } /** * Checks if the column needs measuring, and then measures it. *

* Called by {@link Escalator#onLoad()}. */ public boolean measureAndSetWidthIfNeeded() { assert isAttached() : "Column.measureAndSetWidthIfNeeded() was called even though Escalator was not attached!"; if (measuringRequested) { measuringRequested = false; setWidth(definedWidth); return true; } return false; } private void calculateWidth() { calculatedWidth = getMaxCellWidth(columns.indexOf(this)); } } private final List columns = new ArrayList<>(); private int frozenColumns = 0; /* * TODO: this is a bit of a duplicate functionality with the * Column.calculatedWidth caching. Probably should use one or the other, * not both */ /** * A cached array of all the calculated column widths. * * @see #getCalculatedColumnWidths() */ private double[] widthsArray = null; /** * {@inheritDoc} *

* Implementation detail: This method does no DOM modifications * (i.e. is very cheap to call) if there are no rows in the DOM when * this method is called. * * @see #hasSomethingInDom() */ @Override public void removeColumns(final int index, final int numberOfColumns) { if (numberOfColumns == 0) { return; } // Validate assertArgumentsAreValidAndWithinRange(index, numberOfColumns); // Move the horizontal scrollbar to the left, if removed columns are // to the left of the viewport removeColumnsAdjustScrollbar(index, numberOfColumns); // Remove from DOM header.paintRemoveColumns(index, numberOfColumns); body.paintRemoveColumns(index, numberOfColumns); footer.paintRemoveColumns(index, numberOfColumns); // Remove from bookkeeping flyweightRow.removeCells(index, numberOfColumns); columns.subList(index, index + numberOfColumns).clear(); // Adjust frozen columns if (index < getFrozenColumnCount()) { if (index + numberOfColumns < frozenColumns) { /* * Last removed column was frozen, meaning that all removed * columns were frozen. Just decrement the number of frozen * columns accordingly. */ frozenColumns -= numberOfColumns; } else { /* * If last removed column was not frozen, we have removed * columns beyond the frozen range, so all remaining frozen * columns are to the left of the removed columns. */ frozenColumns = index; } } scroller.recalculateScrollbarsForVirtualViewport(); body.verifyEscalatorCount(); if (getColumnConfiguration().getColumnCount() > 0) { reapplyRowWidths(header); reapplyRowWidths(body); reapplyRowWidths(footer); } /* * Colspans make any kind of automatic clever content re-rendering * impossible: As soon as anything has colspans, removing one might * reveal further colspans, modifying the DOM structure once again, * ending in a cascade of updates. Because we don't know how the * data is updated. * * So, instead, we don't do anything. The client code is responsible * for re-rendering the content (if so desired). Everything Just * Works (TM) if colspans aren't used. */ } private void reapplyRowWidths(AbstractRowContainer container) { if (container.getRowCount() > 0) { container.reapplyRowWidths(); } } private void removeColumnsAdjustScrollbar(int index, int numberOfColumns) { if (horizontalScrollbar.getOffsetSize() >= horizontalScrollbar .getScrollSize()) { return; } double leftPosOfFirstColumnToRemove = getCalculatedColumnsWidth( Range.between(0, index)); double widthOfColumnsToRemove = getCalculatedColumnsWidth( Range.withLength(index, numberOfColumns)); double scrollLeft = horizontalScrollbar.getScrollPos(); if (scrollLeft <= leftPosOfFirstColumnToRemove) { /* * viewport is scrolled to the left of the first removed column, * so there's no need to adjust anything */ return; } double adjustedScrollLeft = Math.max(leftPosOfFirstColumnToRemove, scrollLeft - widthOfColumnsToRemove); horizontalScrollbar.setScrollPos(adjustedScrollLeft); } /** * Calculate the width of a row, as the sum of columns' widths. * * @return the width of a row, in pixels */ public double calculateRowWidth() { return getCalculatedColumnsWidth( Range.between(0, getColumnCount())); } private void assertArgumentsAreValidAndWithinRange(final int index, final int numberOfColumns) { if (numberOfColumns < 1) { throw new IllegalArgumentException( "Number of columns can't be less than 1 (was " + numberOfColumns + ")"); } if (index < 0 || index + numberOfColumns > getColumnCount()) { throw new IndexOutOfBoundsException("The given " + "column range (" + index + ".." + (index + numberOfColumns) + ") was outside of the current " + "number of columns (" + getColumnCount() + ")"); } } /** * {@inheritDoc} *

* Implementation detail: This method does no DOM modifications * (i.e. is very cheap to call) if there is no data for rows when this * method is called. * * @see #hasColumnAndRowData() */ @Override public void insertColumns(final int index, final int numberOfColumns) { if (numberOfColumns == 0) { return; } // Validate if (index < 0 || index > getColumnCount()) { throw new IndexOutOfBoundsException("The given index(" + index + ") was outside of the current number of columns (0.." + getColumnCount() + ")"); } if (numberOfColumns < 1) { throw new IllegalArgumentException( "Number of columns must be 1 or greater (was " + numberOfColumns); } // Add to bookkeeping flyweightRow.addCells(index, numberOfColumns); for (int i = 0; i < numberOfColumns; i++) { columns.add(index, new Column()); } // Adjust frozen columns boolean frozen = index < frozenColumns; if (frozen) { frozenColumns += numberOfColumns; } // Add to DOM header.paintInsertColumns(index, numberOfColumns, frozen); body.paintInsertColumns(index, numberOfColumns, frozen); footer.paintInsertColumns(index, numberOfColumns, frozen); // this needs to be before the scrollbar adjustment. boolean scrollbarWasNeeded = horizontalScrollbar .getOffsetSize() < horizontalScrollbar.getScrollSize(); scroller.recalculateScrollbarsForVirtualViewport(); boolean scrollbarIsNowNeeded = horizontalScrollbar .getOffsetSize() < horizontalScrollbar.getScrollSize(); if (!scrollbarWasNeeded && scrollbarIsNowNeeded) { // This might as a side effect move rows around (when scrolled // all the way down) and require the DOM to be up to date, i.e. // the column to be added body.verifyEscalatorCount(); } // fix initial width if (header.getRowCount() > 0 || body.getRowCount() > 0 || footer.getRowCount() > 0) { Map colWidths = new HashMap<>(); Double width = Double.valueOf(Column.DEFAULT_COLUMN_WIDTH_PX); for (int i = index; i < index + numberOfColumns; i++) { Integer col = Integer.valueOf(i); colWidths.put(col, width); } getColumnConfiguration().setColumnWidths(colWidths); } // Adjust scrollbar double pixelsToInsertedColumn = columnConfiguration .getCalculatedColumnsWidth(Range.withLength(0, index)); final boolean columnsWereAddedToTheLeftOfViewport = scroller.lastScrollLeft > pixelsToInsertedColumn; if (columnsWereAddedToTheLeftOfViewport) { double insertedColumnsWidth = columnConfiguration .getCalculatedColumnsWidth( Range.withLength(index, numberOfColumns)); horizontalScrollbar.setScrollPos( scroller.lastScrollLeft + insertedColumnsWidth); } /* * Colspans make any kind of automatic clever content re-rendering * impossible: As soon as anything has colspans, adding one might * affect surrounding colspans, modifying the DOM structure once * again, ending in a cascade of updates. Because we don't know how * the data is updated. * * So, instead, we don't do anything. The client code is responsible * for re-rendering the content (if so desired). Everything Just * Works (TM) if colspans aren't used. */ } @Override public int getColumnCount() { return columns.size(); } @Override public void setFrozenColumnCount(int count) throws IllegalArgumentException { if (count < 0 || count > getColumnCount()) { throw new IllegalArgumentException( "count must be between 0 and the current number of columns (" + getColumnCount() + ")"); } int oldCount = frozenColumns; if (count == oldCount) { return; } frozenColumns = count; if (hasSomethingInDom()) { // Are we freezing or unfreezing? boolean frozen = count > oldCount; int firstAffectedCol; int firstUnaffectedCol; if (frozen) { firstAffectedCol = oldCount; firstUnaffectedCol = count; } else { firstAffectedCol = count; firstUnaffectedCol = oldCount; } if (oldCount > 0) { header.setColumnLastFrozen(oldCount - 1, false); body.setColumnLastFrozen(oldCount - 1, false); footer.setColumnLastFrozen(oldCount - 1, false); } if (count > 0) { header.setColumnLastFrozen(count - 1, true); body.setColumnLastFrozen(count - 1, true); footer.setColumnLastFrozen(count - 1, true); } for (int col = firstAffectedCol; col < firstUnaffectedCol; col++) { header.setColumnFrozen(col, frozen); body.setColumnFrozen(col, frozen); footer.setColumnFrozen(col, frozen); } } scroller.recalculateScrollbarsForVirtualViewport(); } @Override public int getFrozenColumnCount() { return frozenColumns; } @Override public void setColumnWidth(int index, double px) throws IllegalArgumentException { setColumnWidths(Collections.singletonMap(Integer.valueOf(index), Double.valueOf(px))); } @Override public void setColumnWidths(Map indexWidthMap) throws IllegalArgumentException { if (indexWidthMap == null) { throw new IllegalArgumentException("indexWidthMap was null"); } if (indexWidthMap.isEmpty()) { return; } Profiler.enter("Escalator.ColumnConfigurationImpl.setColumnWidths"); try { for (Entry entry : indexWidthMap.entrySet()) { int index = entry.getKey().intValue(); double width = entry.getValue().doubleValue(); checkValidColumnIndex(index); // Not all browsers will accept any fractional size.. width = WidgetUtil.roundSizeDown(width); columns.get(index).setWidth(width); } widthsArray = null; header.reapplyColumnWidths(); body.reapplyColumnWidths(); footer.reapplyColumnWidths(); recalculateElementSizes(); } finally { Profiler.leave( "Escalator.ColumnConfigurationImpl.setColumnWidths"); } } private void checkValidColumnIndex(int index) throws IllegalArgumentException { if (!Range.withLength(0, getColumnCount()).contains(index)) { throw new IllegalArgumentException("The given column index (" + index + ") does not exist"); } } @Override public double getColumnWidth(int index) throws IllegalArgumentException { checkValidColumnIndex(index); return columns.get(index).getDefinedWidth(); } @Override public double getColumnWidthActual(int index) { return columns.get(index).getCalculatedWidth(); } private double getMaxCellWidth(int colIndex) throws IllegalArgumentException { double headerWidth = header.measureMinCellWidth(colIndex, true); double bodyWidth = body.measureMinCellWidth(colIndex, true); double footerWidth = footer.measureMinCellWidth(colIndex, true); double maxWidth = Math.max(headerWidth, Math.max(bodyWidth, footerWidth)); if (maxWidth < 0 && header.getRowCount() == 0 && body.getRowCount() == 0 && footer.getRowCount() == 0) { maxWidth = 0; } assert maxWidth >= 0 : "Got a negative max width for a column, which should be impossible."; return maxWidth; } private double getMinCellWidth(int colIndex) throws IllegalArgumentException { double headerWidth = header.measureMinCellWidth(colIndex, false); double bodyWidth = body.measureMinCellWidth(colIndex, false); double footerWidth = footer.measureMinCellWidth(colIndex, false); double minWidth = Math.max(headerWidth, Math.max(bodyWidth, footerWidth)); if (minWidth < 0 && header.getRowCount() == 0 && body.getRowCount() == 0 && footer.getRowCount() == 0) { minWidth = 0; } assert minWidth >= 0 : "Got a negative min width for a column, which should be impossible."; return minWidth; } /** * Calculates the width of the columns in a given range. * * @param columns * the columns to calculate * @return the total width of the columns in the given * columns */ double getCalculatedColumnsWidth(final Range columns) { /* * This is an assert instead of an exception, since this is an * internal method. */ assert columns .isSubsetOf(Range.between(0, getColumnCount())) : "Range " + "was outside of current column range (i.e.: " + Range.between(0, getColumnCount()) + ", but was given :" + columns; double sum = 0; for (int i = columns.getStart(); i < columns.getEnd(); i++) { double columnWidthActual = getColumnWidthActual(i); sum += columnWidthActual; } return sum; } double[] getCalculatedColumnWidths() { if (widthsArray == null || widthsArray.length != getColumnCount()) { widthsArray = new double[getColumnCount()]; for (int i = 0; i < columns.size(); i++) { widthsArray[i] = columns.get(i).getCalculatedWidth(); } } return widthsArray; } @Override public void refreshColumns(int index, int numberOfColumns) throws IndexOutOfBoundsException, IllegalArgumentException { if (numberOfColumns < 1) { throw new IllegalArgumentException( "Number of columns must be 1 or greater (was " + numberOfColumns + ")"); } if (index < 0 || index + numberOfColumns > getColumnCount()) { throw new IndexOutOfBoundsException("The given " + "column range (" + index + ".." + (index + numberOfColumns) + ") was outside of the current number of columns (" + getColumnCount() + ")"); } header.refreshColumns(index, numberOfColumns); body.refreshColumns(index, numberOfColumns); footer.refreshColumns(index, numberOfColumns); } } /** * A decision on how to measure a spacer when it is partially within a * designated range. *

* The meaning of each value may differ depending on the context it is being * used in. Check that particular method's JavaDoc. */ private enum SpacerInclusionStrategy { /** A representation of "the entire spacer". */ COMPLETE, /** A representation of "a partial spacer". */ PARTIAL, /** A representation of "no spacer at all". */ NONE } private class SpacerContainer { /** This is used mainly for testing purposes */ private static final String SPACER_LOGICAL_ROW_PROPERTY = "vLogicalRow"; private final class SpacerImpl implements Spacer { private TableCellElement spacerElement; private TableRowElement root; private DivElement deco; private int rowIndex; private double height = -1; private boolean domHasBeenSetup = false; private double decoHeight; private double defaultCellBorderBottomSize = -1; public SpacerImpl(int rowIndex) { this.rowIndex = rowIndex; root = TableRowElement.as(DOM.createTR()); spacerElement = TableCellElement.as(DOM.createTD()); root.appendChild(spacerElement); root.setPropertyInt(SPACER_LOGICAL_ROW_PROPERTY, rowIndex); deco = DivElement.as(DOM.createDiv()); } public void setPositionDiff(double x, double y) { setPosition(getLeft() + x, getTop() + y); } public void setupDom(double height) { assert !domHasBeenSetup : "DOM can't be set up twice."; assert RootPanel.get().getElement().isOrHasChild( root) : "Root element should've been attached to the DOM by now."; domHasBeenSetup = true; getRootElement().getStyle().setWidth(getInnerWidth(), Unit.PX); setHeight(height); spacerElement .setColSpan(getColumnConfiguration().getColumnCount()); setStylePrimaryName(getStylePrimaryName()); } public TableRowElement getRootElement() { return root; } @Override public Element getDecoElement() { return deco; } public void setPosition(double x, double y) { positions.set(getRootElement(), x, y); positions.set(getDecoElement(), 0, y - getSpacerDecoTopOffset()); } private double getSpacerDecoTopOffset() { return getBody().getDefaultRowHeight(); } public void setStylePrimaryName(String style) { UIObject.setStylePrimaryName(root, style + "-spacer"); UIObject.setStylePrimaryName(deco, style + "-spacer-deco"); } /** * Clear spacer height without moving other contents. * * @see #setHeight(double) */ private void clearHeight() { height = 0; root.getStyle().setHeight(0, Unit.PX); updateDecoratorGeometry(0); } public void setHeight(double height) { assert height >= 0 : "Height must be more >= 0 (was " + height + ")"; final double heightDiff = height - Math.max(0, this.height); final double oldHeight = this.height; this.height = height; // since the spacer might be rendered on top of the previous // rows border (done with css), need to increase height the // amount of the border thickness if (defaultCellBorderBottomSize < 0) { defaultCellBorderBottomSize = WidgetUtil .getBorderBottomThickness(body .getRowElement( getVisibleRowRange().getStart()) .getFirstChildElement()); } root.getStyle().setHeight(height + defaultCellBorderBottomSize, Unit.PX); // move the visible spacers getRow row onwards. shiftSpacerPositionsAfterRow(getRow(), heightDiff); /* * If we're growing, we'll adjust the scroll size first, then * adjust scrolling. If we're shrinking, we do it after the * second if-clause. */ boolean spacerIsGrowing = heightDiff > 0; if (spacerIsGrowing) { verticalScrollbar.setScrollSize( verticalScrollbar.getScrollSize() + heightDiff); } /* * Don't modify the scrollbars if we're expanding the -1 spacer * while we're scrolled to the top. */ boolean minusOneSpacerException = spacerIsGrowing && getRow() == -1 && body.getTopRowLogicalIndex() == 0; boolean viewportNeedsScrolling = getRow() < body .getTopRowLogicalIndex() && !minusOneSpacerException; if (viewportNeedsScrolling) { /* * We can't use adjustScrollPos here, probably because of a * bookkeeping-related race condition. * * This particular situation is easier, however, since we * know exactly how many pixels we need to move (heightDiff) * and all elements below the spacer always need to move * that pixel amount. */ for (TableRowElement row : body.visualRowOrder) { body.setRowPosition(row, 0, body.getRowTop(row) + heightDiff); } double top = getTop(); double bottom = top + oldHeight; double scrollTop = verticalScrollbar.getScrollPos(); boolean viewportTopIsAtMidSpacer = top < scrollTop && scrollTop < bottom; final double moveDiff; if (viewportTopIsAtMidSpacer && !spacerIsGrowing) { /* * If the scroll top is in the middle of the modified * spacer, we want to scroll the viewport up as usual, * but we don't want to scroll past the top of it. * * Math.max ensures this (remember: the result is going * to be negative). */ moveDiff = Math.max(heightDiff, top - scrollTop); } else { moveDiff = heightDiff; } body.setBodyScrollPosition(tBodyScrollLeft, tBodyScrollTop + moveDiff); verticalScrollbar.setScrollPosByDelta(moveDiff); } else { body.shiftRowPositions(getRow(), heightDiff); } if (!spacerIsGrowing) { verticalScrollbar.setScrollSize( verticalScrollbar.getScrollSize() + heightDiff); } updateDecoratorGeometry(height); } /** Resizes and places the decorator. */ private void updateDecoratorGeometry(double detailsHeight) { Style style = deco.getStyle(); decoHeight = detailsHeight + getBody().getDefaultRowHeight(); style.setHeight(decoHeight, Unit.PX); } @Override public Element getElement() { return spacerElement; } @Override public int getRow() { return rowIndex; } public double getHeight() { assert height >= 0 : "Height was not previously set by setHeight."; return height; } public double getTop() { return positions.getTop(getRootElement()); } public double getLeft() { return positions.getLeft(getRootElement()); } /** * Sets a new row index for this spacer. Also updates the * bookkeeping at {@link SpacerContainer#rowIndexToSpacer}. */ @SuppressWarnings("boxing") public void setRowIndex(int rowIndex) { SpacerImpl spacer = rowIndexToSpacer.remove(this.rowIndex); assert this == spacer : "trying to move an unexpected spacer."; int oldIndex = this.rowIndex; this.rowIndex = rowIndex; root.setPropertyInt(SPACER_LOGICAL_ROW_PROPERTY, rowIndex); rowIndexToSpacer.put(this.rowIndex, this); fireEvent(new SpacerIndexChangedEvent(oldIndex, this.rowIndex)); } /** * Updates the spacer's visibility parameters, based on whether it * is being currently visible or not. * * @deprecated Escalator no longer uses this logic at initialisation * as there can only be a limited number of spacers and * hidden spacers within visual range interfere with * position calculations. */ @Deprecated public void updateVisibility() { if (isInViewport()) { show(); } else { hide(); } } private boolean isInViewport() { int top = (int) Math.ceil(getTop()); int height = (int) Math.floor(getHeight()); Range location = Range.withLength(top, height); return getViewportPixels().intersects(location); } public void show() { getRootElement().getStyle().clearDisplay(); getDecoElement().getStyle().clearDisplay(); fireEvent(new SpacerVisibilityChangedEvent(getRow(), true)); } public void hide() { getRootElement().getStyle().setDisplay(Display.NONE); getDecoElement().getStyle().setDisplay(Display.NONE); fireEvent(new SpacerVisibilityChangedEvent(getRow(), false)); } /** * Crop the decorator element so that it doesn't overlap the header * and footer sections. * * @param bodyTop * the top cordinate of the escalator body * @param bodyBottom * the bottom cordinate of the escalator body * @param decoWidth * width of the deco */ private void updateDecoClip(final double bodyTop, final double bodyBottom, final double decoWidth) { final int top = deco.getAbsoluteTop(); final int bottom = deco.getAbsoluteBottom(); /* * FIXME * * Height and its use is a workaround for the issue where * coordinates of the deco are not calculated yet. This will * prevent a deco from being displayed when it's added to DOM */ final int height = bottom - top; if (top < bodyTop || bottom > bodyBottom) { final double topClip = Math.max(0.0D, bodyTop - top); final double bottomClip = height - Math.max(0.0D, bottom - bodyBottom); // TODO [optimize] not sure how GWT compiles this final String clip = new StringBuilder("rect(") .append(topClip).append("px,").append(decoWidth) .append("px,").append(bottomClip).append("px,0)") .toString(); deco.getStyle().setProperty("clip", clip); } else { deco.getStyle().setProperty("clip", "auto"); } } } private final TreeMap rowIndexToSpacer = new TreeMap<>(); private SpacerUpdater spacerUpdater = SpacerUpdater.NULL; private final ScrollHandler spacerScroller = new ScrollHandler() { private double prevScrollX = 0; @Override public void onScroll(ScrollEvent event) { if (WidgetUtil.pixelValuesEqual(getScrollLeft(), prevScrollX)) { return; } prevScrollX = getScrollLeft(); for (SpacerImpl spacer : rowIndexToSpacer.values()) { spacer.setPosition(prevScrollX, spacer.getTop()); } } }; private HandlerRegistration spacerScrollerRegistration; /** Width of the spacers' decos. Calculated once then cached. */ private double spacerDecoWidth = 0.0D; public void setSpacer(int rowIndex, double height) throws IllegalArgumentException { if (rowIndex < -1 || rowIndex >= getBody().getRowCount()) { throw new IllegalArgumentException("invalid row index: " + rowIndex + ", while the body only has " + getBody().getRowCount() + " rows."); } if (height >= 0) { if (!spacerExists(rowIndex)) { insertNewSpacer(rowIndex, height); } else { updateExistingSpacer(rowIndex, height); } } else if (spacerExists(rowIndex)) { removeSpacer(rowIndex); } updateSpacerDecosVisibility(); } /** Checks if a given element is a spacer element */ public boolean isSpacer(Element row) { /* * If this needs optimization, we could do a more heuristic check * based on stylenames and stuff, instead of iterating through the * map. */ for (SpacerImpl spacer : rowIndexToSpacer.values()) { if (spacer.getRootElement().equals(row)) { return true; } } return false; } @SuppressWarnings("boxing") void scrollToSpacer(int spacerIndex, ScrollDestination destination, int padding) { assert !destination.equals(ScrollDestination.MIDDLE) || padding != 0 : "destination/padding check should be done before this method"; body.scrollToRowSpacerOrBoth(spacerIndex, destination, padding, ScrollType.SPACER); } public void reapplySpacerWidths() { // FIXME #16266 , spacers get couple pixels too much because borders final double width = getInnerWidth() - spacerDecoWidth; for (SpacerImpl spacer : rowIndexToSpacer.values()) { spacer.getRootElement().getStyle().setWidth(width, Unit.PX); } } /** * @deprecated This method is no longer used by Escalator and is likely * to be removed soon. * * @param removedRowsRange */ @Deprecated public void paintRemoveSpacers(Range removedRowsRange) { removeSpacers(removedRowsRange); shiftSpacersByRows(removedRowsRange.getStart(), -removedRowsRange.length()); } /** * Removes spacers of the given range without moving other contents. *

* NOTE: Changed functionality since 8.9. Previous incarnation of this * method updated the positions of all the contents below the first * removed spacer. * * @param removedRange * logical range of spacers to remove */ @SuppressWarnings("boxing") public void removeSpacers(Range removedRange) { Map removedSpacers = rowIndexToSpacer.subMap( removedRange.getStart(), true, removedRange.getEnd(), false); if (removedSpacers.isEmpty()) { return; } double specialSpacerHeight = removedRange.contains(-1) ? getSpacerHeight(-1) : 0; for (Entry entry : removedSpacers.entrySet()) { SpacerImpl spacer = entry.getValue(); rowIndexToSpacer.remove(entry.getKey()); destroySpacerContent(spacer); spacer.clearHeight(); spacer.getRootElement().removeFromParent(); spacer.getDecoElement().removeFromParent(); } removedSpacers.clear(); if (rowIndexToSpacer.isEmpty()) { assert spacerScrollerRegistration != null : "Spacer scroller registration was null"; spacerScrollerRegistration.removeHandler(); spacerScrollerRegistration = null; } // if a rowless spacer at the top got removed, all rows and spacers // need to be moved up accordingly if (!WidgetUtil.pixelValuesEqual(specialSpacerHeight, 0)) { double scrollDiff = Math.min(specialSpacerHeight, getScrollTop()); body.moveViewportAndContent(null, -specialSpacerHeight, -specialSpacerHeight, -scrollDiff); } } public Map getSpacers() { return new HashMap<>(rowIndexToSpacer); } /** * Calculates the sum of all spacers. * * @return sum of all spacers, or 0 if no spacers present */ public double getSpacerHeightsSum() { return getHeights(rowIndexToSpacer.values()); } /** * Calculates the sum of all spacers from one row index onwards. * * @param logicalRowIndex * the spacer to include as the first calculated spacer * @return the sum of all spacers from {@code logicalRowIndex} and * onwards, or 0 if no suitable spacers were found */ @SuppressWarnings("boxing") public Collection getSpacersForRowAndAfter( int logicalRowIndex) { return new ArrayList<>( rowIndexToSpacer.tailMap(logicalRowIndex, true).values()); } /** * Get all spacers from one pixel point onwards. *

* * In this method, the {@link SpacerInclusionStrategy} has the following * meaning when a spacer lies in the middle of either pixel argument: *

*
{@link SpacerInclusionStrategy#COMPLETE COMPLETE} *
include the spacer *
{@link SpacerInclusionStrategy#PARTIAL PARTIAL} *
include the spacer *
{@link SpacerInclusionStrategy#NONE NONE} *
ignore the spacer *
* * @param px * the pixel point after which to return all spacers * @param strategy * the inclusion strategy regarding the {@code px} * @return a collection of the spacers that exist after {@code px} */ public Collection getSpacersAfterPx(final double px, final SpacerInclusionStrategy strategy) { List spacers = new ArrayList<>( rowIndexToSpacer.values()); for (int i = 0; i < spacers.size(); i++) { SpacerImpl spacer = spacers.get(i); double top = spacer.getTop(); double bottom = top + spacer.getHeight(); if (top > px) { return spacers.subList(i, spacers.size()); } else if (bottom > px) { if (strategy == SpacerInclusionStrategy.NONE) { return spacers.subList(i + 1, spacers.size()); } else { return spacers.subList(i, spacers.size()); } } } return Collections.emptySet(); } /** * Gets the spacers currently rendered in the DOM. * * @return an unmodifiable (but live) collection of the spacers * currently in the DOM */ public Collection getSpacersInDom() { return Collections .unmodifiableCollection(rowIndexToSpacer.values()); } /** * Gets the amount of pixels occupied by spacers between two pixel * points. *

* In this method, the {@link SpacerInclusionStrategy} has the following * meaning when a spacer lies in the middle of either pixel argument: *

*
{@link SpacerInclusionStrategy#COMPLETE COMPLETE} *
take the entire spacer into account *
{@link SpacerInclusionStrategy#PARTIAL PARTIAL} *
take only the visible area into account *
{@link SpacerInclusionStrategy#NONE NONE} *
ignore that spacer *
* * @param rangeTop * the top pixel point * @param topInclusion * the inclusion strategy regarding {@code rangeTop}. * @param rangeBottom * the bottom pixel point * @param bottomInclusion * the inclusion strategy regarding {@code rangeBottom}. * @return the pixels occupied by spacers between {@code rangeTop} and * {@code rangeBottom} */ public double getSpacerHeightsSumBetweenPx(double rangeTop, SpacerInclusionStrategy topInclusion, double rangeBottom, SpacerInclusionStrategy bottomInclusion) { assert rangeTop <= rangeBottom : "rangeTop must be less than rangeBottom"; double heights = 0; /* * TODO [[optimize]]: this might be somewhat inefficient (due to * iterator-based scanning, instead of using the treemap's search * functionalities). But it should be easy to write, read, verify * and maintain. */ for (SpacerImpl spacer : rowIndexToSpacer.values()) { double top = spacer.getTop(); double height = spacer.getHeight(); double bottom = top + height; /* * If we happen to implement a DoubleRange (in addition to the * int-based Range) at some point, the following logic should * probably be converted into using the * Range.partitionWith-equivalent. */ boolean topIsAboveRange = top < rangeTop; boolean topIsInRange = rangeTop <= top && top <= rangeBottom; boolean topIsBelowRange = rangeBottom < top; boolean bottomIsAboveRange = bottom < rangeTop; boolean bottomIsInRange = rangeTop <= bottom && bottom <= rangeBottom; boolean bottomIsBelowRange = rangeBottom < bottom; assert topIsAboveRange ^ topIsBelowRange ^ topIsInRange : "Bad top logic"; assert bottomIsAboveRange ^ bottomIsBelowRange ^ bottomIsInRange : "Bad bottom logic"; if (bottomIsAboveRange) { continue; } else if (topIsBelowRange) { return heights; } else if (topIsAboveRange && bottomIsInRange) { switch (topInclusion) { case PARTIAL: heights += bottom - rangeTop; break; case COMPLETE: heights += height; break; default: break; } } else if (topIsAboveRange && bottomIsBelowRange) { /* * Here we arbitrarily decide that the top inclusion will * have the honor of overriding the bottom inclusion if * happens to be a conflict of interests. */ switch (topInclusion) { case NONE: return 0; case COMPLETE: return height; case PARTIAL: return rangeBottom - rangeTop; default: throw new IllegalArgumentException( "Unexpected inclusion state :" + topInclusion); } } else if (topIsInRange && bottomIsInRange) { heights += height; } else if (topIsInRange && bottomIsBelowRange) { switch (bottomInclusion) { case PARTIAL: heights += rangeBottom - top; break; case COMPLETE: heights += height; break; default: break; } return heights; } else { assert false : "Unnaccounted-for situation"; } } return heights; } /** * Gets the amount of pixels occupied by spacers from the top until a * certain spot from the top of the body. * * @param px * pixels counted from the top * @return the pixels occupied by spacers up until {@code px} */ public double getSpacerHeightsSumUntilPx(double px) { return getSpacerHeightsSumBetweenPx(0, SpacerInclusionStrategy.PARTIAL, px, SpacerInclusionStrategy.PARTIAL); } /** * Gets the amount of pixels occupied by spacers until a logical row * index. The spacer of the row corresponding with the given index isn't * included. * * @param logicalIndex * a logical row index * @return the pixels occupied by spacers up until {@code logicalIndex} */ @SuppressWarnings("boxing") public double getSpacerHeightsSumUntilIndex(int logicalIndex) { return getHeights( rowIndexToSpacer.headMap(logicalIndex, false).values()); } private double getHeights(Collection spacers) { double heights = 0; for (SpacerImpl spacer : spacers) { heights += spacer.getHeight(); } return heights; } /** * Gets the height of the spacer for a row index. * * @param rowIndex * the index of the row where the spacer should be * @return the height of the spacer at index {@code rowIndex}, or 0 if * there is no spacer there */ public double getSpacerHeight(int rowIndex) { SpacerImpl spacer = getSpacer(rowIndex); if (spacer != null) { return spacer.getHeight(); } else { return 0; } } private boolean spacerExists(int rowIndex) { return rowIndexToSpacer.containsKey(Integer.valueOf(rowIndex)); } @SuppressWarnings("boxing") private void insertNewSpacer(int rowIndex, double height) { if (spacerScrollerRegistration == null) { spacerScrollerRegistration = addScrollHandler(spacerScroller); } final SpacerImpl spacer = new SpacerImpl(rowIndex); rowIndexToSpacer.put(rowIndex, spacer); // set the position before adding it to DOM positions.set(spacer.getRootElement(), getScrollLeft(), calculateSpacerTop(rowIndex)); TableRowElement spacerRoot = spacer.getRootElement(); spacerRoot.getStyle() .setWidth(columnConfiguration.calculateRowWidth(), Unit.PX); body.getElement().appendChild(spacerRoot); spacer.setupDom(height); // set the deco position, requires that spacer is in the DOM positions.set(spacer.getDecoElement(), 0, spacer.getTop() - spacer.getSpacerDecoTopOffset()); spacerDecoContainer.appendChild(spacer.getDecoElement()); if (spacerDecoContainer.getParentElement() == null) { getElement().appendChild(spacerDecoContainer); // calculate the spacer deco width, it won't change spacerDecoWidth = getBoundingWidth(spacer.getDecoElement()); } initSpacerContent(spacer); // schedule updating of the physical indexes body.domSorter.reschedule(); } private void updateExistingSpacer(int rowIndex, double newHeight) { getSpacer(rowIndex).setHeight(newHeight); } public SpacerImpl getSpacer(int rowIndex) { return rowIndexToSpacer.get(Integer.valueOf(rowIndex)); } private void removeSpacer(int rowIndex) { removeSpacers(Range.withOnly(rowIndex)); } public void setStylePrimaryName(String style) { for (SpacerImpl spacer : rowIndexToSpacer.values()) { spacer.setStylePrimaryName(style); } } public void setSpacerUpdater(SpacerUpdater spacerUpdater) throws IllegalArgumentException { if (spacerUpdater == null) { throw new IllegalArgumentException( "spacer updater cannot be null"); } destroySpacerContent(rowIndexToSpacer.values()); this.spacerUpdater = spacerUpdater; initSpacerContent(rowIndexToSpacer.values()); } public SpacerUpdater getSpacerUpdater() { return spacerUpdater; } private void destroySpacerContent(Iterable spacers) { for (SpacerImpl spacer : spacers) { destroySpacerContent(spacer); } } private void destroySpacerContent(SpacerImpl spacer) { assert getElement().isOrHasChild(spacer .getRootElement()) : "Spacer's root element somehow got detached from Escalator before detaching"; assert getElement().isOrHasChild(spacer .getElement()) : "Spacer element somehow got detached from Escalator before detaching"; spacerUpdater.destroy(spacer); assert getElement().isOrHasChild(spacer .getRootElement()) : "Spacer's root element somehow got detached from Escalator before detaching"; assert getElement().isOrHasChild(spacer .getElement()) : "Spacer element somehow got detached from Escalator before detaching"; } private void initSpacerContent(Iterable spacers) { for (SpacerImpl spacer : spacers) { initSpacerContent(spacer); } } private void initSpacerContent(SpacerImpl spacer) { assert getElement().isOrHasChild(spacer .getRootElement()) : "Spacer's root element somehow got detached from Escalator before attaching"; assert getElement().isOrHasChild(spacer .getElement()) : "Spacer element somehow got detached from Escalator before attaching"; spacerUpdater.init(spacer); assert getElement().isOrHasChild(spacer .getRootElement()) : "Spacer's root element somehow got detached from Escalator during attaching"; assert getElement().isOrHasChild(spacer .getElement()) : "Spacer element somehow got detached from Escalator during attaching"; spacer.show(); } public String getSubPartName(Element subElement) { for (SpacerImpl spacer : rowIndexToSpacer.values()) { if (spacer.getRootElement().isOrHasChild(subElement)) { return "spacer[" + spacer.getRow() + "]"; } } return null; } public Element getSubPartElement(int index) { SpacerImpl spacer = rowIndexToSpacer.get(Integer.valueOf(index)); if (spacer != null) { return spacer.getElement(); } else { return null; } } private double calculateSpacerTop(int logicalIndex) { return body.getRowTop(logicalIndex) + body.getDefaultRowHeight(); } @SuppressWarnings("boxing") private void shiftSpacerPositionsAfterRow(int changedRowIndex, double diffPx) { for (SpacerImpl spacer : rowIndexToSpacer .tailMap(changedRowIndex, false).values()) { spacer.setPositionDiff(0, diffPx); } } /** * Shifts spacers at and after a specific row by an amount of rows that * don't contain spacers of their own. *

* This moves both their associated logical row index and also their * visual placement. *

* Note: This method does not check for the validity of any * arguments. * * @param index * the index of first row to move * @param numberOfRows * the number of rows to shift the spacers with. A positive * value is downwards, a negative value is upwards. */ public void shiftSpacersByRows(int index, int numberOfRows) { final double pxDiff = numberOfRows * body.getDefaultRowHeight(); List spacers = new ArrayList<>( getSpacersForRowAndAfter(index)); if (numberOfRows < 0) { for (SpacerContainer.SpacerImpl spacer : spacers) { spacer.setPositionDiff(0, pxDiff); spacer.setRowIndex(spacer.getRow() + numberOfRows); } } else { for (int i = spacers.size() - 1; i >= 0; --i) { SpacerContainer.SpacerImpl spacer = spacers.get(i); spacer.setPositionDiff(0, pxDiff); spacer.setRowIndex(spacer.getRow() + numberOfRows); } } } /** * Update the associated logical row indexes for spacers without moving * their actual positions. *

* Note: This method does not check for the validity of any * arguments. * * @param startIndex * the previous logical index of first row to update * @param endIndex * the previous logical index of first row that doesn't need * updating anymore * @param numberOfRows * the number of rows to shift the associated logical index * with. A positive value is downwards, a negative value is * upwards. */ private void updateSpacerIndexesForRowAndAfter(int startIndex, int endIndex, int numberOfRows) { List spacers = new ArrayList<>( getSpacersForRowAndAfter(startIndex)); spacers.removeAll(getSpacersForRowAndAfter(endIndex)); if (numberOfRows < 0) { for (SpacerContainer.SpacerImpl spacer : spacers) { spacer.setRowIndex(spacer.getRow() + numberOfRows); } } else { for (int i = spacers.size() - 1; i >= 0; --i) { SpacerContainer.SpacerImpl spacer = spacers.get(i); spacer.setRowIndex(spacer.getRow() + numberOfRows); } } } private void updateSpacerDecosVisibility() { final Range visibleRowRange = getVisibleRowRange(); Collection visibleSpacers = rowIndexToSpacer .subMap(visibleRowRange.getStart() - 1, visibleRowRange.getEnd() + 1) .values(); if (!visibleSpacers.isEmpty()) { final double top = tableWrapper.getAbsoluteTop() + header.getHeightOfSection(); final double bottom = tableWrapper.getAbsoluteBottom() - footer.getHeightOfSection(); for (SpacerImpl spacer : visibleSpacers) { spacer.updateDecoClip(top, bottom, spacerDecoWidth); } } } } private class ElementPositionBookkeeper { /** * A map containing cached values of an element's current top position. */ private final Map elementTopPositionMap = new HashMap<>(); private final Map elementLeftPositionMap = new HashMap<>(); public void set(final Element e, final double x, final double y) { assert e != null : "Element was null"; position.set(e, x, y); elementTopPositionMap.put(e, Double.valueOf(y)); elementLeftPositionMap.put(e, Double.valueOf(x)); } public double getTop(final Element e) { Double top = elementTopPositionMap.get(e); if (top == null) { throw new IllegalArgumentException("Element " + e + " was not found in the position bookkeeping"); } return top.doubleValue(); } public double getLeft(final Element e) { Double left = elementLeftPositionMap.get(e); if (left == null) { throw new IllegalArgumentException("Element " + e + " was not found in the position bookkeeping"); } return left.doubleValue(); } public void remove(Element e) { elementTopPositionMap.remove(e); elementLeftPositionMap.remove(e); } } /** * Utility class for parsing and storing SubPart request string attributes * for Grid and Escalator. * * @since 7.5.0 */ public static class SubPartArguments { private String type; private int[] indices; private SubPartArguments(String type, int[] indices) { /* * The constructor is private so that no third party would by * mistake start using this parsing scheme, since it's not official * by TestBench (yet?). */ this.type = type; this.indices = indices; } public String getType() { return type; } public int getIndicesLength() { return indices.length; } public int getIndex(int i) { return indices[i]; } public int[] getIndices() { return Arrays.copyOf(indices, indices.length); } static SubPartArguments create(String subPart) { String[] splitArgs = subPart.split("\\["); String type = splitArgs[0]; int[] indices = new int[splitArgs.length - 1]; for (int i = 0; i < indices.length; ++i) { String tmp = splitArgs[i + 1]; indices[i] = Integer .parseInt(tmp.substring(0, tmp.indexOf("]", 1))); } return new SubPartArguments(type, indices); } } enum ScrollType { ROW, SPACER, ROW_AND_SPACER } // abs(atan(y/x))*(180/PI) = n deg, x = 1, solve y /** * The solution to * |tan-1(x)|×(180/π) = 30 * . *

* This constant is placed in the Escalator class, instead of an inner * class, since even mathematical expressions aren't allowed in non-static * inner classes for constants. */ private static final double RATIO_OF_30_DEGREES = 1 / Math.sqrt(3); /** * The solution to * |tan-1(x)|×(180/π) = 40 * . *

* This constant is placed in the Escalator class, instead of an inner * class, since even mathematical expressions aren't allowed in non-static * inner classes for constants. */ private static final double RATIO_OF_40_DEGREES = Math.tan(2 * Math.PI / 9); private static final String DEFAULT_WIDTH = "500.0px"; private static final String DEFAULT_HEIGHT = "400.0px"; private FlyweightRow flyweightRow = new FlyweightRow(); /** The {@code } tag. */ private final TableSectionElement headElem = TableSectionElement .as(DOM.createTHead()); /** The {@code } tag. */ private final TableSectionElement bodyElem = TableSectionElement .as(DOM.createTBody()); /** The {@code } tag. */ private final TableSectionElement footElem = TableSectionElement .as(DOM.createTFoot()); /** * TODO: investigate whether this field is now unnecessary, as * {@link ScrollbarBundle} now caches its values. * * @deprecated maybe... */ @Deprecated private double tBodyScrollTop = 0; /** * TODO: investigate whether this field is now unnecessary, as * {@link ScrollbarBundle} now caches its values. * * @deprecated maybe... */ @Deprecated private double tBodyScrollLeft = 0; private final VerticalScrollbarBundle verticalScrollbar = new VerticalScrollbarBundle(); private final HorizontalScrollbarBundle horizontalScrollbar = new HorizontalScrollbarBundle(); private final AriaGridHelper ariaGridHelper = new AriaGridHelper(); private final HeaderRowContainer header = new HeaderRowContainer(headElem); private final BodyRowContainerImpl body = new BodyRowContainerImpl( bodyElem); private final FooterRowContainer footer = new FooterRowContainer(footElem); /** * Flag for keeping track of {@link RowHeightChangedEvent}s */ private boolean rowHeightChangedEventFired = false; private final Scroller scroller = new Scroller(); private final ColumnConfigurationImpl columnConfiguration = new ColumnConfigurationImpl(); private final DivElement tableWrapper; private final Element table; private final DivElement horizontalScrollbarDeco = DivElement .as(DOM.createDiv()); private final DivElement headerDeco = DivElement.as(DOM.createDiv()); private final DivElement footerDeco = DivElement.as(DOM.createDiv()); private final DivElement spacerDecoContainer = DivElement .as(DOM.createDiv()); private PositionFunction position; /** The cached width of the escalator, in pixels. */ private double widthOfEscalator = 0; /** The cached height of the escalator, in pixels. */ private double heightOfEscalator = 0; /** The height of Escalator in terms of body rows. */ private double heightByRows = 10.0d; /** The height of Escalator, as defined by {@link #setHeight(String)} */ private String heightByCss = ""; private HeightMode heightMode = HeightMode.CSS; private double delayToCancelTouchScroll = -1; private boolean layoutIsScheduled = false; private ScheduledCommand layoutCommand = () -> { // ensure that row heights have been set or auto-detected if // auto-detection is already possible, because visibility changes might // not trigger the default check that happens in onLoad() header.autodetectRowHeightLater(); body.autodetectRowHeightLater(); footer.autodetectRowHeightLater(); recalculateElementSizes(); layoutIsScheduled = false; }; private final ElementPositionBookkeeper positions = new ElementPositionBookkeeper(); /** * Creates a new Escalator widget instance. */ public Escalator() { detectAndApplyPositionFunction(); getLogger().info("Using " + position.getClass().getSimpleName() + " for position"); final Element root = DOM.createDiv(); setElement(root); setupScrollbars(root); tableWrapper = DivElement.as(DOM.createDiv()); Event.sinkEvents(tableWrapper, Event.ONSCROLL | Event.KEYEVENTS); Event.setEventListener(tableWrapper, event -> { if (event.getKeyCode() != KeyCodes.KEY_TAB) { return; } boolean browserScroll = tableWrapper.getScrollLeft() != 0 || tableWrapper.getScrollTop() != 0; boolean keyEvent = event.getType().startsWith("key"); if (browserScroll || keyEvent) { // Browser is scrolling our div automatically, reset tableWrapper.setScrollLeft(0); tableWrapper.setScrollTop(0); Element focused = WidgetUtil.getFocusedElement(); Stream.of(header, body, footer).forEach(container -> { Cell cell = container.getCell(focused); if (cell == null) { return; } scrollToColumn(cell.getColumn(), ScrollDestination.ANY, 0); if (container == body) { scrollToRow(cell.getRow(), ScrollDestination.ANY, 0); } }); } }); root.appendChild(tableWrapper); table = DOM.createTable(); tableWrapper.appendChild(table); table.appendChild(headElem); table.appendChild(bodyElem); table.appendChild(footElem); Style hCornerStyle = headerDeco.getStyle(); hCornerStyle.setWidth(verticalScrollbar.getScrollbarThickness(), Unit.PX); hCornerStyle.setDisplay(Display.NONE); root.appendChild(headerDeco); Style fCornerStyle = footerDeco.getStyle(); fCornerStyle.setWidth(verticalScrollbar.getScrollbarThickness(), Unit.PX); fCornerStyle.setDisplay(Display.NONE); root.appendChild(footerDeco); Style hWrapperStyle = horizontalScrollbarDeco.getStyle(); hWrapperStyle.setDisplay(Display.NONE); hWrapperStyle.setHeight(horizontalScrollbar.getScrollbarThickness(), Unit.PX); root.appendChild(horizontalScrollbarDeco); setStylePrimaryName("v-escalator"); spacerDecoContainer.setAttribute("aria-hidden", "true"); // init default dimensions setHeight(null); setWidth(null); publishJSHelpers(root); } private double getBoundingWidth(Element element) { // Gets the current width, including border and padding, for the element // while ignoring any transforms applied to the element (e.g. scale) return new ComputedStyle(element).getWidthIncludingBorderPadding(); } private double getBoundingHeight(Element element) { // Gets the current height, including border and padding, for the // element while ignoring any transforms applied to the element (e.g. // scale) return new ComputedStyle(element).getHeightIncludingBorderPadding(); } private int getBodyRowCount() { return getBody().getRowCount(); } private native void publishJSHelpers(Element root) /*-{ var self = this; root.getBodyRowCount = $entry(function () { return self.@Escalator::getBodyRowCount()(); }); }-*/; private void setupScrollbars(final Element root) { ScrollHandler scrollHandler = event -> { scroller.onScroll(); fireEvent(new ScrollEvent()); }; int scrollbarThickness = WidgetUtil.getNativeScrollbarSize(); if (BrowserInfo.get().isIE()) { /* * IE refuses to scroll properly if the DIV isn't at least one pixel * larger than the scrollbar controls themselves. */ scrollbarThickness += 1; } root.appendChild(verticalScrollbar.getElement()); verticalScrollbar.addScrollHandler(scrollHandler); verticalScrollbar.setScrollbarThickness(scrollbarThickness); root.appendChild(horizontalScrollbar.getElement()); horizontalScrollbar.addScrollHandler(scrollHandler); horizontalScrollbar.setScrollbarThickness(scrollbarThickness); horizontalScrollbar .addVisibilityHandler(new ScrollbarBundle.VisibilityHandler() { private boolean queued = false; @Override public void visibilityChanged( ScrollbarBundle.VisibilityChangeEvent event) { if (queued) { return; } queued = true; /* * We either lost or gained a scrollbar. In any case, we * need to change the height, if it's defined by rows. */ Scheduler.get().scheduleFinally(() -> { applyHeightByRows(); queued = false; }); } }); /* * Because of all the IE hacks we've done above, we now have scrollbars * hiding underneath a lot of DOM elements. * * This leads to problems with OSX (and many touch-only devices) when * scrollbars are only shown when scrolling, as the scrollbar elements * are hidden underneath everything. We trust that the scrollbars behave * properly in these situations and simply pop them out with a bit of * z-indexing. */ if (WidgetUtil.getNativeScrollbarSize() == 0) { verticalScrollbar.getElement().getStyle().setZIndex(90); horizontalScrollbar.getElement().getStyle().setZIndex(90); } } @Override protected void onLoad() { super.onLoad(); // ensure that row heights have been set or auto-detected if // auto-detection is already possible, if not the check will be // performed again in layoutCommand header.autodetectRowHeightLater(); body.autodetectRowHeightLater(); footer.autodetectRowHeightLater(); header.paintInsertRows(0, header.getRowCount()); footer.paintInsertRows(0, footer.getRowCount()); boolean columnsChanged = false; for (ColumnConfigurationImpl.Column column : columnConfiguration.columns) { boolean columnChanged = column.measureAndSetWidthIfNeeded(); if (columnChanged) { columnsChanged = true; } } if (columnsChanged) { header.reapplyColumnWidths(); body.reapplyColumnWidths(); footer.reapplyColumnWidths(); } verticalScrollbar.onLoad(); horizontalScrollbar.onLoad(); scroller.attachScrollListener(verticalScrollbar.getElement()); scroller.attachScrollListener(horizontalScrollbar.getElement()); scroller.attachMousewheelListener(getElement()); if (isCurrentBrowserIE11OrEdge()) { // Touch listeners doesn't work for IE11 and Edge (#18737) scroller.attachPointerEventListeners(getElement()); } else { scroller.attachTouchListeners(getElement()); } /* * Note: There's no need to explicitly insert rows into the body. * * recalculateElementSizes will recalculate the height of the body. This * has the side-effect that as the body's size grows bigger (i.e. from 0 * to its actual height), more escalator rows are populated. Those * escalator rows are then immediately rendered. This, in effect, is the * same thing as inserting those rows. * * In fact, having an extra paintInsertRows here would lead to duplicate * rows. */ recalculateElementSizes(); } @Override protected void onUnload() { scroller.detachScrollListener(verticalScrollbar.getElement()); scroller.detachScrollListener(horizontalScrollbar.getElement()); scroller.detachMousewheelListener(getElement()); if (isCurrentBrowserIE11OrEdge()) { // Touch listeners doesn't work for IE11 and Edge (#18737) scroller.detachPointerEventListeners(getElement()); } else { scroller.detachTouchListeners(getElement()); } /* * We can call paintRemoveRows here, because static ranges are simple to * remove. */ header.paintRemoveRows(0, header.getRowCount()); footer.paintRemoveRows(0, footer.getRowCount()); /* * We can't call body.paintRemoveRows since it relies on rowCount to be * updated correctly. Since it isn't, we'll simply and brutally rip out * the DOM elements (in an elegant way, of course). */ int rowsToRemove = body.getDomRowCount(); for (int i = 0; i < rowsToRemove; i++) { int index = rowsToRemove - i - 1; TableRowElement tr = bodyElem.getRows().getItem(index); body.paintRemoveRow(tr, index); positions.remove(tr); } body.visualRowOrder.clear(); body.setTopRowLogicalIndex(0); super.onUnload(); } private void detectAndApplyPositionFunction() { final Style docStyle = Document.get().getBody().getStyle(); if (hasProperty(docStyle, "transform")) { if (hasProperty(docStyle, "transformStyle")) { position = new Translate3DPosition(); } else { position = new TranslatePosition(); } } else if (hasProperty(docStyle, "webkitTransform")) { position = new WebkitTranslate3DPosition(); } } private Logger getLogger() { return Logger.getLogger(getClass().getName()); } private static native boolean hasProperty(Style style, String name) /*-{ return style[name] !== undefined; }-*/; /** * Check whether there are both columns and any row data (for either * headers, body or footer). * * @return true if header, body or footer has rows and there * are columns */ private boolean hasColumnAndRowData() { return (header.getRowCount() > 0 || body.getRowCount() > 0 || footer.getRowCount() > 0) && columnConfiguration.getColumnCount() > 0; } /** * Check whether there are any cells in the DOM. * * @return true if header, body or footer has any child * elements */ private boolean hasSomethingInDom() { return headElem.hasChildNodes() || bodyElem.hasChildNodes() || footElem.hasChildNodes(); } /** * Returns the row container for the header in this Escalator. * * @return the header. Never null */ public RowContainer getHeader() { return header; } /** * Returns the row container for the body in this Escalator. * * @return the body. Never null */ public BodyRowContainer getBody() { return body; } /** * Returns the row container for the footer in this Escalator. * * @return the footer. Never null */ public RowContainer getFooter() { return footer; } /** * Returns the configuration object for the columns in this Escalator. * * @return the configuration object for the columns in this Escalator. Never * null */ public ColumnConfiguration getColumnConfiguration() { return columnConfiguration; } @Override public void setWidth(final String width) { if (width != null && !width.isEmpty()) { super.setWidth(width); } else { super.setWidth(DEFAULT_WIDTH); } recalculateElementSizes(); } /** * {@inheritDoc} *

* If Escalator is currently not in {@link HeightMode#CSS}, the given value * is remembered, and applied once the mode is applied. * * @see #setHeightMode(HeightMode) */ @Override public void setHeight(String height) { /* * TODO remove method once RequiresResize and the Vaadin layoutmanager * listening mechanisms are implemented */ if (height != null && !height.isEmpty()) { heightByCss = height; } else { if (getHeightMode() == HeightMode.UNDEFINED) { heightByRows = body.getRowCount(); applyHeightByRows(); return; } else { heightByCss = DEFAULT_HEIGHT; } } if (getHeightMode() == HeightMode.CSS) { setHeightInternal(height); } } private void setHeightInternal(final String height) { final int escalatorRowsBefore = body.visualRowOrder.size(); if (height != null && !height.isEmpty()) { super.setHeight(height); } else { if (getHeightMode() == HeightMode.UNDEFINED) { int newHeightByRows = body.getRowCount(); if (heightByRows != newHeightByRows) { heightByRows = newHeightByRows; applyHeightByRows(); } return; } else { super.setHeight(DEFAULT_HEIGHT); } } recalculateElementSizes(); if (escalatorRowsBefore != body.visualRowOrder.size()) { fireRowVisibilityChangeEvent(); } } /** * Returns the vertical scroll offset. Note that this is not necessarily the * same as the {@code scrollTop} attribute in the DOM. * * @return the logical vertical scroll offset */ public double getScrollTop() { return verticalScrollbar.getScrollPos(); } /** * Sets the vertical scroll offset. Note that this will not necessarily * become the same as the {@code scrollTop} attribute in the DOM. * * @param scrollTop * the number of pixels to scroll vertically */ public void setScrollTop(final double scrollTop) { verticalScrollbar.setScrollPos(scrollTop); } /** * Returns the logical horizontal scroll offset. Note that this is not * necessarily the same as the {@code scrollLeft} attribute in the DOM. * * @return the logical horizontal scroll offset */ public double getScrollLeft() { return horizontalScrollbar.getScrollPos(); } /** * Sets the logical horizontal scroll offset. Note that will not necessarily * become the same as the {@code scrollLeft} attribute in the DOM. * * @param scrollLeft * the number of pixels to scroll horizontally */ public void setScrollLeft(final double scrollLeft) { horizontalScrollbar.setScrollPos(scrollLeft); } /** * Returns the scroll width for the escalator. Note that this is not * necessary the same as {@code Element.scrollWidth} in the DOM. * * @since 7.5.0 * @return the scroll width in pixels */ public double getScrollWidth() { return horizontalScrollbar.getScrollSize(); } /** * Returns the scroll height for the escalator. Note that this is not * necessary the same as {@code Element.scrollHeight} in the DOM. * * @since 7.5.0 * @return the scroll height in pixels */ public double getScrollHeight() { return verticalScrollbar.getScrollSize(); } /** * Scrolls the body horizontally so that the column at the given index is * visible and there is at least {@code padding} pixels in the direction of * the given scroll destination. * * @param columnIndex * the index of the column to scroll to * @param destination * where the column should be aligned visually after scrolling * @param padding * the number pixels to place between the scrolled-to column and * the viewport edge. * @throws IndexOutOfBoundsException * if {@code columnIndex} is not a valid index for an existing * column * @throws IllegalArgumentException * if {@code destination} is {@link ScrollDestination#MIDDLE} * and padding is nonzero; or if the indicated column is frozen; * or if {@code destination == null} */ public void scrollToColumn(final int columnIndex, final ScrollDestination destination, final int padding) throws IndexOutOfBoundsException, IllegalArgumentException { validateScrollDestination(destination, padding); verifyValidColumnIndex(columnIndex); if (columnIndex < columnConfiguration.frozenColumns) { throw new IllegalArgumentException( "The given column index " + columnIndex + " is frozen."); } scroller.scrollToColumn(columnIndex, destination, padding); } private void verifyValidColumnIndex(final int columnIndex) throws IndexOutOfBoundsException { if (columnIndex < 0 || columnIndex >= columnConfiguration.getColumnCount()) { throw new IndexOutOfBoundsException("The given column index " + columnIndex + " does not exist."); } } /** * Scrolls the body vertically so that the row at the given index is visible * and there is at least {@literal padding} pixels to the given scroll * destination. * * @param rowIndex * the index of the logical row to scroll to * @param destination * where the row should be aligned visually after scrolling * @param padding * the number pixels to place between the scrolled-to row and the * viewport edge. * @throws IndexOutOfBoundsException * if {@code rowIndex} is not a valid index for an existing row * @throws IllegalArgumentException * if {@code destination} is {@link ScrollDestination#MIDDLE} * and padding is nonzero; or if {@code destination == null} * @see #scrollToRowAndSpacer(int, ScrollDestination, int) * @see #scrollToSpacer(int, ScrollDestination, int) */ public void scrollToRow(final int rowIndex, final ScrollDestination destination, final int padding) throws IndexOutOfBoundsException, IllegalArgumentException { verifyValidRowIndex(rowIndex); body.scrollToRowSpacerOrBoth(rowIndex, destination, padding, ScrollType.ROW); } private void verifyValidRowIndex(final int rowIndex) { if (rowIndex < 0 || rowIndex >= body.getRowCount()) { throw new IndexOutOfBoundsException( "The given row index " + rowIndex + " does not exist."); } } /** * Scrolls the body vertically so that the spacer at the given row index is * visible and there is at least {@literal padding} pixels to the given * scroll destination. * * @since 7.5.0 * @param spacerIndex * the row index of the spacer to scroll to * @param destination * where the spacer should be aligned visually after scrolling * @param padding * the number of pixels to place between the scrolled-to spacer * and the viewport edge * @throws IllegalArgumentException * if {@code spacerIndex} is not an opened spacer; or if * {@code destination} is {@link ScrollDestination#MIDDLE} and * padding is nonzero; or if {@code destination == null} * @see #scrollToRow(int, ScrollDestination, int) * @see #scrollToRowAndSpacer(int, ScrollDestination, int) */ public void scrollToSpacer(final int spacerIndex, ScrollDestination destination, final int padding) throws IllegalArgumentException { body.scrollToRowSpacerOrBoth(spacerIndex, destination, padding, ScrollType.SPACER); } /** * Scrolls vertically to a row and the spacer below it. *

* If a spacer is not open at that index, this method behaves like * {@link #scrollToRow(int, ScrollDestination, int)} * * @since 7.5.0 * @param rowIndex * the index of the logical row to scroll to. -1 takes the * topmost spacer into account as well. * @param destination * where the row should be aligned visually after scrolling * @param padding * the number pixels to place between the scrolled-to row and the * viewport edge. * @see #scrollToRow(int, ScrollDestination, int) * @see #scrollToSpacer(int, ScrollDestination, int) * @throws IllegalArgumentException * if {@code destination} is {@link ScrollDestination#MIDDLE} * and {@code padding} is not zero; or if {@code rowIndex} is * not a valid row index, or -1; or if * {@code destination == null}; or if {@code rowIndex == -1} and * there is no spacer open at that index. */ public void scrollToRowAndSpacer(final int rowIndex, final ScrollDestination destination, final int padding) throws IllegalArgumentException { if (rowIndex != -1) { verifyValidRowIndex(rowIndex); } body.scrollToRowSpacerOrBoth(rowIndex, destination, padding, ScrollType.ROW_AND_SPACER); } private static void validateScrollDestination( final ScrollDestination destination, final int padding) { if (destination == null) { throw new IllegalArgumentException("Destination cannot be null"); } if (destination == ScrollDestination.MIDDLE && padding != 0) { throw new IllegalArgumentException( "You cannot have a padding with a MIDDLE destination"); } } /** * Recalculates the dimensions for all elements that require manual * calculations. Also updates the dimension caches. *

* Note: This method has the side-effect * automatically makes sure that an appropriate amount of escalator rows are * present. So, if the body area grows, more escalator rows might be * inserted. Conversely, if the body area shrinks, * escalator rows might be removed. */ private void recalculateElementSizes() { if (!isAttached()) { return; } Profiler.enter("Escalator.recalculateElementSizes"); widthOfEscalator = Math.max(0, getBoundingWidth(getElement())); heightOfEscalator = Math.max(0, getBoundingHeight(getElement())); header.recalculateSectionHeight(); body.recalculateSectionHeight(); footer.recalculateSectionHeight(); scroller.recalculateScrollbarsForVirtualViewport(); body.verifyEscalatorCount(); body.reapplySpacerWidths(); Profiler.leave("Escalator.recalculateElementSizes"); } /** * Snap deltas of x and y to the major four axes (up, down, left, right) * with a threshold of a number of degrees from those axes. * * @param deltaX * the delta in the x axis * @param deltaY * the delta in the y axis * @param thresholdRatio * the threshold in ratio (0..1) between x and y for when to snap * @return a two-element array: [snappedX, snappedY] */ private static double[] snapDeltas(final double deltaX, final double deltaY, final double thresholdRatio) { final double[] array = new double[2]; if (deltaX != 0 && deltaY != 0) { final double aDeltaX = Math.abs(deltaX); final double aDeltaY = Math.abs(deltaY); final double yRatio = aDeltaY / aDeltaX; final double xRatio = aDeltaX / aDeltaY; array[0] = (xRatio < thresholdRatio) ? 0 : deltaX; array[1] = (yRatio < thresholdRatio) ? 0 : deltaY; } else { array[0] = deltaX; array[1] = deltaY; } return array; } /** * Adds an event handler that gets notified when the range of visible rows * changes e.g. because of scrolling, row resizing or spacers * appearing/disappearing. * * @param rowVisibilityChangeHandler * the event handler * @return a handler registration for the added handler */ public HandlerRegistration addRowVisibilityChangeHandler( RowVisibilityChangeHandler rowVisibilityChangeHandler) { return addHandler(rowVisibilityChangeHandler, RowVisibilityChangeEvent.TYPE); } private void fireRowVisibilityChangeEvent() { if (!body.visualRowOrder.isEmpty()) { int visibleRangeStart = body.getTopRowLogicalIndex(); int visibleRowCount = body.visualRowOrder.size(); fireEvent(new RowVisibilityChangeEvent(visibleRangeStart, visibleRowCount)); } else { fireEvent(new RowVisibilityChangeEvent(0, 0)); } } /** * Gets the logical index range of currently visible rows. * * @return logical index range of visible rows */ public Range getVisibleRowRange() { if (!body.visualRowOrder.isEmpty()) { return Range.withLength(body.getTopRowLogicalIndex(), body.visualRowOrder.size()); } else { return Range.withLength(0, 0); } } /** * Returns the widget from a cell node or null if there is no * widget in the cell * * @param cellNode * The cell node */ static Widget getWidgetFromCell(Node cellNode) { Node possibleWidgetNode = cellNode.getFirstChild(); if (possibleWidgetNode != null && possibleWidgetNode.getNodeType() == Node.ELEMENT_NODE) { @SuppressWarnings("deprecation") com.google.gwt.user.client.Element castElement = (com.google.gwt.user.client.Element) possibleWidgetNode .cast(); Widget w = WidgetUtil.findWidget(castElement); // Ensure findWidget did not traverse past the cell element in the // DOM hierarchy if (cellNode.isOrHasChild(w.getElement())) { return w; } } return null; } @Override public void setStylePrimaryName(String style) { super.setStylePrimaryName(style); verticalScrollbar.setStylePrimaryName(style); horizontalScrollbar.setStylePrimaryName(style); UIObject.setStylePrimaryName(tableWrapper, style + "-tablewrapper"); UIObject.setStylePrimaryName(headerDeco, style + "-header-deco"); UIObject.setStylePrimaryName(footerDeco, style + "-footer-deco"); UIObject.setStylePrimaryName(horizontalScrollbarDeco, style + "-horizontal-scrollbar-deco"); UIObject.setStylePrimaryName(spacerDecoContainer, style + "-spacer-deco-container"); header.setStylePrimaryName(style); body.setStylePrimaryName(style); footer.setStylePrimaryName(style); } /** * Sets the number of rows that should be visible in Escalator's body, while * {@link #getHeightMode()} is {@link HeightMode#ROW}. *

* If Escalator is currently not in {@link HeightMode#ROW}, the given value * is remembered, and applied once the mode is applied. * * @param rows * the number of rows that should be visible in Escalator's body * @throws IllegalArgumentException * if {@code rows} is ≤ 0, {@link Double#isInfinite(double) * infinite} or {@link Double#isNaN(double) NaN}. * @see #setHeightMode(HeightMode) */ public void setHeightByRows(double rows) throws IllegalArgumentException { if (heightMode == HeightMode.UNDEFINED && body.insertingOrRemoving) { // this will be called again once the operation is finished, ignore // for now return; } if (rows < 0) { throw new IllegalArgumentException( "The number of rows must be a positive number."); } else if (Double.isInfinite(rows)) { throw new IllegalArgumentException( "The number of rows must be finite."); } else if (Double.isNaN(rows)) { throw new IllegalArgumentException("The number must not be NaN."); } heightByRows = rows; applyHeightByRows(); } /** * Gets the amount of rows in Escalator's body that are shown, while * {@link #getHeightMode()} is {@link HeightMode#ROW}. *

* By default, it is 10. * * @return the amount of rows that are being shown in Escalator's body * @see #setHeightByRows(double) */ public double getHeightByRows() { return heightByRows; } /** * Reapplies the row-based height of the Grid, if Grid currently should * define its height that way. */ private void applyHeightByRows() { if (heightMode != HeightMode.ROW && heightMode != HeightMode.UNDEFINED) { return; } double headerHeight = header.getHeightOfSection(); double footerHeight = footer.getHeightOfSection(); double bodyHeight = body.getDefaultRowHeight() * heightByRows; double scrollbar = horizontalScrollbar.showsScrollHandle() ? horizontalScrollbar.getScrollbarThickness() : 0; double spacerHeight = 0; // ignored if HeightMode.ROW if (heightMode == HeightMode.UNDEFINED) { spacerHeight = body.spacerContainer.getSpacerHeightsSum(); } double totalHeight = headerHeight + bodyHeight + spacerHeight + scrollbar + footerHeight; setHeightInternal(totalHeight + "px"); } /** * Defines the mode in which the Escalator widget's height is calculated. *

* If {@link HeightMode#CSS} is given, Escalator will respect the values * given via {@link #setHeight(String)}, and behave as a traditional Widget. *

* If {@link HeightMode#ROW} is given, Escalator will make sure that the * {@link #getBody() body} will display as many rows as * {@link #getHeightByRows()} defines. Note: If headers/footers are * inserted or removed, the widget will resize itself to still display the * required amount of rows in its body. It also takes the horizontal * scrollbar into account. * * @param heightMode * the mode in to which Escalator should be set */ public void setHeightMode(HeightMode heightMode) { /* * This method is a workaround for the fact that Vaadin re-applies * widget dimensions (height/width) on each state change event. The * original design was to have setHeight an setHeightByRow be equals, * and whichever was called the latest was considered in effect. * * But, because of Vaadin always calling setHeight on the widget, this * approach doesn't work. */ if (heightMode != this.heightMode) { this.heightMode = heightMode; switch (this.heightMode) { case CSS: setHeight(heightByCss); break; case ROW: setHeightByRows(heightByRows); break; case UNDEFINED: setHeightByRows(body.getRowCount()); break; default: throw new IllegalStateException("Unimplemented feature " + "- unknown HeightMode: " + this.heightMode); } } } /** * Returns the current {@link HeightMode} the Escalator is in. *

* Defaults to {@link HeightMode#CSS}. * * @return the current HeightMode */ public HeightMode getHeightMode() { return heightMode; } /** * Returns the {@link RowContainer} which contains the element. * * @param element * the element to check for * @return the container the element is in or null if element * is not present in any container. */ public RowContainer findRowContainer(Element element) { if (getHeader().getElement() != element && getHeader().getElement().isOrHasChild(element)) { return getHeader(); } else if (getBody().getElement() != element && getBody().getElement().isOrHasChild(element)) { return getBody(); } else if (getFooter().getElement() != element && getFooter().getElement().isOrHasChild(element)) { return getFooter(); } return null; } /** * Sets whether a scroll direction is locked or not. *

* If a direction is locked, the escalator will refuse to scroll in that * direction. * * @param direction * the orientation of the scroll to set the lock status * @param locked * true to lock, false to unlock */ public void setScrollLocked(ScrollbarBundle.Direction direction, boolean locked) { switch (direction) { case HORIZONTAL: horizontalScrollbar.setLocked(locked); break; case VERTICAL: verticalScrollbar.setLocked(locked); break; default: throw new UnsupportedOperationException( "Unexpected value: " + direction); } } /** * Checks whether or not an direction is locked for scrolling. * * @param direction * the direction of the scroll of which to check the lock status * @return true if the direction is locked */ public boolean isScrollLocked(ScrollbarBundle.Direction direction) { switch (direction) { case HORIZONTAL: return horizontalScrollbar.isLocked(); case VERTICAL: return verticalScrollbar.isLocked(); default: throw new UnsupportedOperationException( "Unexpected value: " + direction); } } /** * Adds a scroll handler to this escalator. * * @param handler * the scroll handler to add * @return a handler registration for the registered scroll handler */ public HandlerRegistration addScrollHandler(ScrollHandler handler) { return addHandler(handler, ScrollEvent.TYPE); } /** * Returns true if the Escalator is currently scrolling by touch, or has not * made the decision yet whether to accept touch actions as scrolling or * not. * * @see #setDelayToCancelTouchScroll(double) * * @return true when the component is touch scrolling at the moment * @since 8.1 */ public boolean isTouchScrolling() { return scroller.touchHandlerBundle.touching; } /** * Returns the time after which to not consider a touch event a scroll event * if the user has not moved the touch. This can be used to differentiate * between quick touch move (scrolling) and long tap (e.g. context menu or * drag and drop operation). * * @return delay in milliseconds after which to cancel touch scrolling if * there is no movement, -1 means scrolling is always allowed * @since 8.1 */ public double getDelayToCancelTouchScroll() { return delayToCancelTouchScroll; } /** * Sets the time after which to not consider a touch event a scroll event if * the user has not moved the touch. This can be used to differentiate * between quick touch move (scrolling) and long tap (e.g. context menu or * drag and drop operation). * * @param delayToCancelTouchScroll * delay in milliseconds after which to cancel touch scrolling if * there is no movement, -1 to always allow scrolling * @since 8.1 */ public void setDelayToCancelTouchScroll(double delayToCancelTouchScroll) { this.delayToCancelTouchScroll = delayToCancelTouchScroll; } @Override public boolean isWorkPending() { return body.domSorter.waiting || verticalScrollbar.isWorkPending() || horizontalScrollbar.isWorkPending() || layoutIsScheduled; } @Override public void onResize() { if (isAttached() && !layoutIsScheduled) { layoutIsScheduled = true; Scheduler.get().scheduleFinally(layoutCommand); } } /** * Gets the maximum number of body rows that can be visible on the screen at * once. * * @return the maximum capacity */ public int getMaxVisibleRowCount() { return body.getMaxVisibleRowCount(); } /** * Gets the escalator's inner width. This is the entire width in pixels, * without the vertical scrollbar. * * @return escalator's inner width */ public double getInnerWidth() { return getBoundingWidth(tableWrapper); } /** * Resets all cached pixel sizes and reads new values from the DOM. This * methods should be used e.g. when styles affecting the dimensions of * elements in this escalator have been changed. */ public void resetSizesFromDom() { header.autodetectRowHeightNow(); body.autodetectRowHeightNow(); footer.autodetectRowHeightNow(); for (int i = 0; i < columnConfiguration.getColumnCount(); i++) { columnConfiguration.setColumnWidth(i, columnConfiguration.getColumnWidth(i)); } } private Range getViewportPixels() { int from = (int) Math.floor(verticalScrollbar.getScrollPos()); int to = (int) body.getHeightOfSection(); return Range.withLength(from, to); } @Override @SuppressWarnings("deprecation") public com.google.gwt.user.client.Element getSubPartElement( String subPart) { SubPartArguments args = SubPartArguments.create(subPart); Element tableStructureElement = getSubPartElementTableStructure(args); if (tableStructureElement != null) { return DOM.asOld(tableStructureElement); } Element spacerElement = getSubPartElementSpacer(args); if (spacerElement != null) { return DOM.asOld(spacerElement); } return null; } /** * Returns the {@code

} * element which has the table inside it. {primary-stylename} is .e.g * {@code v-grid}. *

* NOTE: you should not do any modifications to the returned element. * This API is only available for querying data from the element. * * @return the table wrapper element * @since 8.1 */ public Element getTableWrapper() { return tableWrapper; } /** * Returns the <table> element of the grid. * * @return the table element * @since 8.2 */ public Element getTable() { return table; } private Element getSubPartElementTableStructure(SubPartArguments args) { String type = args.getType(); int[] indices = args.getIndices(); // Get correct RowContainer for type from Escalator RowContainer container = null; if (type.equalsIgnoreCase("header")) { container = getHeader(); } else if (type.equalsIgnoreCase("cell")) { if (indices.length > 0) { // If wanted row is not visible, we need to scroll there. // Scrolling might be a no-op if row is already in the viewport. scrollToRow(indices[0], ScrollDestination.ANY, 0); } container = getBody(); } else if (type.equalsIgnoreCase("footer")) { container = getFooter(); } if (null != container) { if (indices.length == 0) { // No indexing. Just return the wanted container element return container.getElement(); } else { try { return getSubPart(container, indices); } catch (Exception e) { getLogger().log(Level.SEVERE, e.getMessage()); } } } return null; } private Element getSubPart(RowContainer container, int[] indices) { Element targetElement = container.getRowElement(indices[0]); // Scroll wanted column to view if able if (indices.length > 1 && targetElement != null) { if (getColumnConfiguration().getFrozenColumnCount() <= indices[1]) { scrollToColumn(indices[1], ScrollDestination.ANY, 0); } targetElement = getCellFromRow(TableRowElement.as(targetElement), indices[1]); for (int i = 2; i < indices.length && targetElement != null; ++i) { targetElement = (Element) targetElement.getChild(indices[i]); } } return targetElement; } private static Element getCellFromRow(TableRowElement rowElement, int index) { int childCount = rowElement.getCells().getLength(); if (index < 0 || index >= childCount) { return null; } TableCellElement currentCell = null; boolean indexInColspan = false; int i = 0; while (!indexInColspan) { currentCell = rowElement.getCells().getItem(i); // Calculate if this is the cell we are looking for int colSpan = currentCell.getColSpan(); indexInColspan = index < colSpan + i; // Increment by colspan to skip over hidden cells i += colSpan; } return currentCell; } private Element getSubPartElementSpacer(SubPartArguments args) { if ("spacer".equals(args.getType()) && args.getIndicesLength() == 1) { // If spacer's row is not visible, we need to scroll there. // Scrolling might be a no-op if row is already in the viewport. scrollToSpacer(args.getIndex(0), ScrollDestination.ANY, 0); return body.spacerContainer.getSubPartElement(args.getIndex(0)); } else { return null; } } @Override @SuppressWarnings("deprecation") public String getSubPartName( com.google.gwt.user.client.Element subElement) { /* * The spacer check needs to be before table structure check, because * (for now) the table structure will take spacer elements into account * as well, when it shouldn't. */ String spacer = getSubPartNameSpacer(subElement); if (spacer != null) { return spacer; } String tableStructure = getSubPartNameTableStructure(subElement); if (tableStructure != null) { return tableStructure; } return null; } private String getSubPartNameTableStructure(Element subElement) { List containers = Arrays.asList(getHeader(), getBody(), getFooter()); List containerType = Arrays.asList("header", "cell", "footer"); for (int i = 0; i < containers.size(); ++i) { RowContainer container = containers.get(i); boolean containerRow = (subElement.getTagName() .equalsIgnoreCase("tr") && subElement.getParentElement() == container.getElement()); if (containerRow) { /* * Wanted SubPart is row that is a child of containers root to * get indices, we use a cell that is a child of this row */ subElement = subElement.getFirstChildElement(); } Cell cell = container.getCell(subElement); if (cell != null) { // Skip the column index if subElement was a child of root return containerType.get(i) + "[" + cell.getRow() + (containerRow ? "]" : "][" + cell.getColumn() + "]"); } } return null; } private String getSubPartNameSpacer(Element subElement) { return body.spacerContainer.getSubPartName(subElement); } private void logWarning(String message) { getLogger().warning(message); } /** * This is an internal method for calculating minimum width for Column * resize. * * @return minimum width for column */ double getMinCellWidth(int colIndex) { return columnConfiguration.getMinCellWidth(colIndex); } /** * Internal method for checking whether the browser is IE11 or Edge * * @return true only if the current browser is IE11, or Edge */ private static boolean isCurrentBrowserIE11OrEdge() { return BrowserInfo.get().isIE11() || BrowserInfo.get().isEdge(); } }