From ba09414833ccf7c17342cfa776e99b23a4ce2dfb Mon Sep 17 00:00:00 2001 From: palexdev Date: Wed, 8 May 2024 22:38:05 +0200 Subject: [PATCH] --wip-- --- TODO.md | 9 +- .../virtualizedfx/base/VFXContainer.java | 8 +- .../{Paginated.java => VFXPaginated.java} | 2 +- .../virtualizedfx/cells/CellBase.java | 2 +- .../virtualizedfx/cells/MappingTableCell.java | 21 + .../virtualizedfx/cells/TableCell.java | 13 + .../enums/ColumnsLayoutMode.java | 27 + .../enums/GeometryChangeType.java | 8 + .../palexdev/virtualizedfx/grid/VFXGrid.java | 16 - .../virtualizedfx/grid/VFXGridHelper.java | 10 +- .../palexdev/virtualizedfx/list/VFXList.java | 16 - .../virtualizedfx/list/VFXListHelper.java | 10 +- .../virtualizedfx/list/VFXListManager.java | 14 +- .../list/paginated/VFXPaginatedList.java | 6 +- .../properties/VFXTableStateProperty.java | 27 + .../virtualizedfx/table/ColumnsSizeCache.java | 246 +++ .../virtualizedfx/table/VFXTable.java | 597 ++++++ .../virtualizedfx/table/VFXTableColumn.java | 234 +++ .../virtualizedfx/table/VFXTableHelper.java | 628 ++++++ .../virtualizedfx/table/VFXTableManager.java | 508 +++++ .../virtualizedfx/table/VFXTableRow.java | 245 +++ .../virtualizedfx/table/VFXTableSkin.java | 260 +++ .../virtualizedfx/table/VFXTableState.java | 224 +++ .../table/defaults/VFXDefaultTableColumn.java | 263 +++ .../defaults/VFXDefaultTableColumnSkin.java | 134 ++ .../table/defaults/VFXDefaultTableRow.java | 92 + .../table/defaults/VFXSimpleTableCell.java | 200 ++ .../table/defaults/VFXUpdatingTableCell.java | 74 + .../virtualizedfx/utils/IndexBiMap.java | 17 +- src/main/java/module-info.java | 3 + src/test/java/app/Playground.java | 92 +- src/test/java/interactive/TestFXUtils.java | 6 + src/test/java/interactive/grid/GridTests.java | 15 + src/test/java/interactive/list/ListTests.java | 16 +- .../interactive/list/PaginatedListTests.java | 15 + .../table/ColumnsSizeCacheTests.java | 364 ++++ .../interactive/table/TableTestUtils.java | 641 ++++++ .../java/interactive/table/TableTests.java | 1788 +++++++++++++++++ src/test/java/jmh/JMHTestSubMap.java | 84 + src/test/java/misc/IndexBiMapPOCTests.java | 46 + src/test/java/misc/IndexBiMapTests.java | 67 + src/test/java/utils/Utils.java | 36 +- wiki/Table.md | 103 +- 43 files changed, 7071 insertions(+), 116 deletions(-) rename src/main/java/io/github/palexdev/virtualizedfx/base/{Paginated.java => VFXPaginated.java} (97%) create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/cells/MappingTableCell.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/cells/TableCell.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/enums/ColumnsLayoutMode.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/enums/GeometryChangeType.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/properties/VFXTableStateProperty.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/ColumnsSizeCache.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTable.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableColumn.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableHelper.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableManager.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableRow.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableSkin.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableState.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumn.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumnSkin.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableRow.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXSimpleTableCell.java create mode 100644 src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXUpdatingTableCell.java create mode 100644 src/test/java/interactive/table/ColumnsSizeCacheTests.java create mode 100644 src/test/java/interactive/table/TableTestUtils.java create mode 100644 src/test/java/interactive/table/TableTests.java create mode 100644 src/test/java/jmh/JMHTestSubMap.java create mode 100644 src/test/java/misc/IndexBiMapPOCTests.java create mode 100644 src/test/java/misc/IndexBiMapTests.java diff --git a/TODO.md b/TODO.md index ffc61fc..f1d68fb 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,11 @@ ## General -- [ ] Replace `IntegerRage.expandRangeToSet(...)` with a class implementation that keeps track of removed indexes. - This way it is much more efficient. - [ ] Review APIs (classes, simplify as much as possible/re-organize at least) - [ ] Rename cells to VFXxxx (?) - [ ] Implement paginated Grid (?) -- [ ] Implement paginated Table \ No newline at end of file +- [ ] Implement paginated Table +- [ ] Review listeners in skin, in particular, allow constructs to have the same listener to save up memory +- [ ] Make table cells use a Skin (?) +- [ ] Optimize cells to update only if and invalidation occurred (?) +- [ ] Rename special EMPTY state to INVALID +- [ ] Rename absIndex/absIdx to layoutIdx \ No newline at end of file diff --git a/src/main/java/io/github/palexdev/virtualizedfx/base/VFXContainer.java b/src/main/java/io/github/palexdev/virtualizedfx/base/VFXContainer.java index 6eff4d9..24ebb0d 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/base/VFXContainer.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/base/VFXContainer.java @@ -37,7 +37,9 @@ default int size() { /** * Specifies the number of items in the data structure. */ - ReadOnlyIntegerProperty sizeProperty(); + default ReadOnlyIntegerProperty sizeProperty() { + return itemsProperty().sizeProperty(); + } default boolean isEmpty() { return emptyProperty().get(); @@ -46,7 +48,9 @@ default boolean isEmpty() { /** * Specifies whether the data set is empty. */ - ReadOnlyBooleanProperty emptyProperty(); + default ReadOnlyBooleanProperty emptyProperty() { + return itemsProperty().emptyProperty(); + } /** * Delegate for {@link VFXListHelper#getVirtualMaxX()} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/base/Paginated.java b/src/main/java/io/github/palexdev/virtualizedfx/base/VFXPaginated.java similarity index 97% rename from src/main/java/io/github/palexdev/virtualizedfx/base/Paginated.java rename to src/main/java/io/github/palexdev/virtualizedfx/base/VFXPaginated.java index 7aaa8f8..70f2efb 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/base/Paginated.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/base/VFXPaginated.java @@ -6,7 +6,7 @@ /** * Defines the common API for every paginated virtualized container offered by VirtualizedFX. Extends {@link VFXContainer}. */ -public interface Paginated extends VFXContainer { +public interface VFXPaginated extends VFXContainer { default int getPage() { return pageProperty().get(); diff --git a/src/main/java/io/github/palexdev/virtualizedfx/cells/CellBase.java b/src/main/java/io/github/palexdev/virtualizedfx/cells/CellBase.java index 99603b9..c31218e 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/cells/CellBase.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/cells/CellBase.java @@ -43,7 +43,7 @@ public abstract class CellBase extends Control> implement //================================================================================ // Properties //================================================================================ - private final IntegerProperty index = new SimpleIntegerProperty(); + private final IntegerProperty index = new SimpleIntegerProperty(-1); private final ObjectProperty item = new SimpleObjectProperty<>(); //================================================================================ diff --git a/src/main/java/io/github/palexdev/virtualizedfx/cells/MappingTableCell.java b/src/main/java/io/github/palexdev/virtualizedfx/cells/MappingTableCell.java new file mode 100644 index 0000000..3a15044 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/cells/MappingTableCell.java @@ -0,0 +1,21 @@ +package io.github.palexdev.virtualizedfx.cells; + +import io.github.palexdev.mfxcore.utils.converters.FunctionalStringConverter; +import javafx.util.StringConverter; + +import java.util.function.Function; + +public interface MappingTableCell extends TableCell { + + Function getExtractor(); + + void setExtractor(Function extractor); + + StringConverter getConverter(); + + void setConverter(StringConverter converter); + + default void setConverter(Function converter) { + setConverter(FunctionalStringConverter.to(converter)); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/cells/TableCell.java b/src/main/java/io/github/palexdev/virtualizedfx/cells/TableCell.java new file mode 100644 index 0000000..aa189b7 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/cells/TableCell.java @@ -0,0 +1,13 @@ +package io.github.palexdev.virtualizedfx.cells; + +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import io.github.palexdev.virtualizedfx.table.VFXTableRow; + +public interface TableCell extends Cell { + + default void updateColumn(VFXTableColumn> column) {} + + default void updateRow(VFXTableRow row) {} + + default void invalidate() {} +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/enums/ColumnsLayoutMode.java b/src/main/java/io/github/palexdev/virtualizedfx/enums/ColumnsLayoutMode.java new file mode 100644 index 0000000..c0da4fa --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/enums/ColumnsLayoutMode.java @@ -0,0 +1,27 @@ +package io.github.palexdev.virtualizedfx.enums; + +import io.github.palexdev.mfxcore.utils.EnumUtils; +import io.github.palexdev.virtualizedfx.table.VFXTable; + +/** + * Enumerator to specify the layout modes for columns in {@link VFXTable}. + */ +public enum ColumnsLayoutMode { + + /** + * In this mode, all columns will have the same width specified by {@link VFXTable#columnsSizeProperty()}. + */ + FIXED, + + /** + * In this mode, columns are allowed to have different widths. This enables features like: + * columns auto-sizing ({@link VariableTableHelper#autosizeColumn(TableColumn)}), or resizing at runtime + * through gestures (not implemented by the default column type) + */ + VARIABLE, + ; + + public static ColumnsLayoutMode next(ColumnsLayoutMode mode) { + return EnumUtils.next(ColumnsLayoutMode.class, mode); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/enums/GeometryChangeType.java b/src/main/java/io/github/palexdev/virtualizedfx/enums/GeometryChangeType.java new file mode 100644 index 0000000..51b794a --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/enums/GeometryChangeType.java @@ -0,0 +1,8 @@ +package io.github.palexdev.virtualizedfx.enums; + +public enum GeometryChangeType { + WIDTH, + HEIGHT, + OTHER, + ; +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGrid.java b/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGrid.java index 63dd2f3..16e9b5e 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGrid.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGrid.java @@ -277,22 +277,6 @@ public VFXGrid populateCache() { return this; } - /** - * Delegate for {@link ListProperty#sizeProperty()}. - */ - @Override - public ReadOnlyIntegerProperty sizeProperty() { - return items.sizeProperty(); - } - - /** - * Delegate for {@link ListProperty#emptyProperty()} - */ - @Override - public ReadOnlyBooleanProperty emptyProperty() { - return items.emptyProperty(); - } - /** * Delegate for {@link VFXGridState#getRowsRange()} */ diff --git a/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGridHelper.java b/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGridHelper.java index 3f2ef9a..4f2db5e 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGridHelper.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/grid/VFXGridHelper.java @@ -467,7 +467,10 @@ public int lastColumn() { */ @Override public int visibleColumns() { - return (int) Math.ceil(grid.getWidth() / getTotalCellSize().getWidth()); + double width = getTotalCellSize().getWidth(); + return width > 0 ? + (int) Math.ceil(grid.getWidth() / width) : + 0; } /** @@ -532,7 +535,10 @@ public int lastRow() { */ @Override public int visibleRows() { - return (int) Math.ceil(grid.getHeight() / getTotalCellSize().getHeight()); + double height = getTotalCellSize().getHeight(); + return height > 0 ? + (int) Math.ceil(grid.getHeight() / height) : + 0; } /** diff --git a/src/main/java/io/github/palexdev/virtualizedfx/list/VFXList.java b/src/main/java/io/github/palexdev/virtualizedfx/list/VFXList.java index 0905465..21e053e 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/list/VFXList.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/list/VFXList.java @@ -243,22 +243,6 @@ public VFXList populateCache() { return this; } - /** - * Delegate for {@link ListProperty#sizeProperty()}. - */ - @Override - public ReadOnlyIntegerProperty sizeProperty() { - return items.sizeProperty(); - } - - /** - * Delegate for {@link ListProperty#emptyProperty()} - */ - @Override - public ReadOnlyBooleanProperty emptyProperty() { - return items.emptyProperty(); - } - /** * Delegate for {@link VFXListState#getRange()} */ diff --git a/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListHelper.java b/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListHelper.java index 5d1c566..9eb5019 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListHelper.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListHelper.java @@ -370,7 +370,10 @@ public int lastVisible() { */ @Override public int visibleNum() { - return (int) Math.ceil(list.getHeight() / getTotalCellSize()); + double size = getTotalCellSize(); + return size > 0 ? + (int) Math.ceil(list.getHeight() / size) : + 0; } /** @@ -554,7 +557,10 @@ public int lastVisible() { */ @Override public int visibleNum() { - return (int) Math.ceil(list.getWidth() / getTotalCellSize()); + double size = getTotalCellSize(); + return size > 0 ? + (int) Math.ceil(list.getWidth() / size) : + 0; } /** diff --git a/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListManager.java b/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListManager.java index 618a171..acec39f 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListManager.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/list/VFXListManager.java @@ -414,12 +414,14 @@ protected void onSpacingChanged() { protected void moveReuseCreateAlgorithm(IntegerRange range, VFXListState newState) { VFXList list = getNode(); VFXListState current = list.getState(); - ExcludingRange eRange = new ExcludingRange(range); - for (Integer index : range) { - C c = current.removeCell(index); - if (c == null) continue; - eRange.exclude(index); - newState.addCell(index, c); + ExcludingRange eRange = ExcludingRange.of(range); + if (!current.isEmpty()) { + for (Integer index : range) { + C c = current.removeCell(index); + if (c == null) continue; + eRange.exclude(index); + newState.addCell(index, c); + } } remainingAlgorithm(eRange, newState); } diff --git a/src/main/java/io/github/palexdev/virtualizedfx/list/paginated/VFXPaginatedList.java b/src/main/java/io/github/palexdev/virtualizedfx/list/paginated/VFXPaginatedList.java index 8bca10c..a158324 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/list/paginated/VFXPaginatedList.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/list/paginated/VFXPaginatedList.java @@ -6,7 +6,7 @@ import io.github.palexdev.mfxcore.controls.SkinBase; import io.github.palexdev.mfxcore.utils.fx.PropUtils; import io.github.palexdev.mfxcore.utils.fx.StyleUtils; -import io.github.palexdev.virtualizedfx.base.Paginated; +import io.github.palexdev.virtualizedfx.base.VFXPaginated; import io.github.palexdev.virtualizedfx.cells.Cell; import io.github.palexdev.virtualizedfx.list.VFXList; import io.github.palexdev.virtualizedfx.list.VFXListHelper; @@ -37,7 +37,7 @@ * Simple and naive implementation of a paginated variant of {@link VFXList}. * The default style class is extended to: '.vfx-list.paginated'. *

- * Extends {@link VFXList}, implements {@link Paginated}, has its own skin {@link VFXPaginatedListSkin} and behavior + * Extends {@link VFXList}, implements {@link VFXPaginated}, has its own skin {@link VFXPaginatedListSkin} and behavior * {@link VFXPaginatedListSkin}. *

* A: What do you mean by naive?

@@ -65,7 +65,7 @@ * {@link #helperFactoryProperty()} that produces helpers of type {@link VFXListHelper}, don't do that! * You may end up with invalid states, thus a broken component. */ -public class VFXPaginatedList> extends VFXList implements Paginated { +public class VFXPaginatedList> extends VFXList implements VFXPaginated { //================================================================================ // Properties //================================================================================ diff --git a/src/main/java/io/github/palexdev/virtualizedfx/properties/VFXTableStateProperty.java b/src/main/java/io/github/palexdev/virtualizedfx/properties/VFXTableStateProperty.java new file mode 100644 index 0000000..68e8c2f --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/properties/VFXTableStateProperty.java @@ -0,0 +1,27 @@ +package io.github.palexdev.virtualizedfx.properties; + +import io.github.palexdev.virtualizedfx.table.VFXTableState; +import javafx.beans.property.ReadOnlyObjectWrapper; + +/** + * Convenience property that extends {@link ReadOnlyObjectWrapper} for {@link VFXTableState}. + */ +public class VFXTableStateProperty extends ReadOnlyObjectWrapper> { + + //================================================================================ + // Constructors + //================================================================================ + public VFXTableStateProperty() {} + + public VFXTableStateProperty(VFXTableState initialValue) { + super(initialValue); + } + + public VFXTableStateProperty(Object bean, String name) { + super(bean, name); + } + + public VFXTableStateProperty(Object bean, String name, VFXTableState initialValue) { + super(bean, name, initialValue); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/ColumnsSizeCache.java b/src/main/java/io/github/palexdev/virtualizedfx/table/ColumnsSizeCache.java new file mode 100644 index 0000000..bd322d1 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/ColumnsSizeCache.java @@ -0,0 +1,246 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.virtualizedfx.cells.TableCell; +import javafx.beans.Observable; +import javafx.beans.binding.DoubleBinding; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +public class ColumnsSizeCache extends DoubleBinding { + //================================================================================ + // Properties + //================================================================================ + private VFXTable table; + private final Map, DoubleBinding> cache; + private ListChangeListener> clListener; + private boolean init = false; + + private VFXTableColumn lColumn; + private final ReadOnlyBooleanWrapper anyChanged = new ReadOnlyBooleanWrapper(false) { + @Override + protected void invalidated() { + if (get()) { + invalidateLast(); + invalidate(); + } + } + }; + + // Cached bindings functions + private BiFunction, Boolean, Double> widthFn; + private Consumer invalidatingAction = last -> { + if (last) invalidate(); + else anyChanged.set(true); + }; + + //================================================================================ + // Constructors + //================================================================================ + public ColumnsSizeCache(VFXTable table) { + this.table = table; + cache = new HashMap<>(); + clListener = this::handleColumns; + } + + public ColumnsSizeCache(VFXTable table, BiFunction, Boolean, Double> widthFn) { + this(table); + init(widthFn); + } + + //================================================================================ + // Methods + //================================================================================ + public void init(BiFunction, Boolean, Double> widthFn) { + if (init) return; + this.widthFn = widthFn; + ObservableList>> columns = table.getColumns(); + if (!columns.isEmpty()) { + lColumn = columns.getLast(); + for (VFXTableColumn> column : columns) createBinding(column, false); + } + columns.addListener(clListener); + init = true; + } + + public double getColumnWidth(VFXTableColumn column) { + return cache.get(column).get(); + } + + public double getLastColumnWidth() { + return getColumnWidth(lColumn); + } + + public double getPartial() { + return cache.entrySet().stream() + .filter(e -> e.getKey() != lColumn) + .mapToDouble(e -> e.getValue().get()) + .sum(); + } + + private void createBinding(VFXTableColumn column, boolean replace) { + Observable[] dependencies = column == lColumn ? + new Observable[]{table.widthProperty(), table.columnsSizeProperty(), column.prefWidthProperty(), anyChanged} : + new Observable[]{table.columnsSizeProperty(), column.prefWidthProperty()}; + DoubleBinding binding = new DoubleBinding() { + { + bind(dependencies); + } + + @Override + protected double computeValue() { + return widthFn.apply(column, column == lColumn); + } + + @Override + protected void onInvalidating() { + invalidatingAction.accept(column == lColumn); + } + + @Override + public void dispose() { + unbind(dependencies); + } + }; + + if (cache.containsKey(column)) { + if (!replace) return; + cache.remove(column).dispose(); + } + cache.put(column, binding); + } + + private void handleColumns(ListChangeListener.Change> change) { + ObservableList>> columns = table.getColumns(); + if (columns.isEmpty()) { + clear(); + invalidate(); + return; + } + + VFXTableColumn> last = columns.getLast(); + if (last != lColumn) { + invalidateLast(); + lColumn = last; + createBinding(lColumn, true); + } + + Set> rm = new HashSet<>(); + while (change.next()) { + if (change.wasRemoved()) rm.addAll(change.getRemoved()); + if (change.wasAdded()) { + for (VFXTableColumn c : change.getAddedSubList()) { + if (rm.contains(c)) { + rm.remove(c); + continue; + } + createBinding(c, false); + } + } + } + rm.forEach(c -> cache.remove(c).dispose()); + + invalidate(); + invalidateLast(); + } + + private void clear() { + cache.values().forEach(DoubleBinding::dispose); + cache.clear(); + anyChanged.set(false); + lColumn = null; + } + + private void invalidateLast() { + DoubleBinding binding = cache.get(lColumn); + if (binding != null && binding.isValid()) binding.invalidate(); + } + + public int size() { + return cache.size(); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + protected double computeValue() { + anyChanged.set(false); + return cache.values().stream() + .mapToDouble(DoubleBinding::get) + .sum(); + } + + @Override + public void dispose() { + clear(); + widthFn = null; + invalidatingAction = null; + table.getColumns().removeListener(clListener); + clListener = null; + table = null; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ColumnsSizeCache [%s][%d] {".formatted(isValid() ? "valid:[%f]".formatted(get()) : "invalid", size())); + if (cache.isEmpty()) { + sb.append("empty}"); + return sb.toString(); + } + sb.append("\n"); + + // Pretty print + int maxL = 0; + for (VFXTableColumn c : cache.keySet()) { + String text = Optional.ofNullable(c.getText()).orElse(""); + maxL = Math.max(maxL, text.length()); + } + + for (Map.Entry, DoubleBinding> entry : cache.entrySet()) { + VFXTableColumn c = entry.getKey(); + DoubleBinding b = entry.getValue(); + String text = Optional.ofNullable(c.getText()).orElse(""); + int spaces = maxL - text.length(); + sb.append(" ") + .append(text) + .append(": %s[%s]".formatted(" ".repeat(spaces), b.isValid() ? "valid:[%f]".formatted(b.get()) : "invalid")) + .append("\n"); + } + sb.append("}"); + return sb.toString(); + } + + //================================================================================ + // Getters/Setters + //================================================================================ + public VFXTable getTable() { + return table; + } + + protected Map, DoubleBinding> getCache() { + return cache; + } + + protected VFXTableColumn getLastColumn() { + return lColumn; + } + + public boolean isAnyChanged() { + return anyChanged.get(); + } + + public ReadOnlyBooleanProperty anyChangedProperty() { + return anyChanged.getReadOnlyProperty(); + } + + public void setWidthFunction(BiFunction, Boolean, Double> widthFn) { + this.widthFn = widthFn; + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTable.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTable.java new file mode 100644 index 0000000..e1faf40 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTable.java @@ -0,0 +1,597 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.beans.Size; +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.mfxcore.base.properties.functional.FunctionProperty; +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableDoubleProperty; +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableIntegerProperty; +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableObjectProperty; +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableSizeProperty; +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableSizeProperty.SizeConverter; +import io.github.palexdev.mfxcore.controls.Control; +import io.github.palexdev.mfxcore.controls.SkinBase; +import io.github.palexdev.mfxcore.utils.fx.PropUtils; +import io.github.palexdev.mfxcore.utils.fx.StyleUtils; +import io.github.palexdev.virtualizedfx.base.VFXContainer; +import io.github.palexdev.virtualizedfx.base.VFXStyleable; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.enums.BufferSize; +import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode; +import io.github.palexdev.virtualizedfx.properties.VFXTableStateProperty; +import io.github.palexdev.virtualizedfx.table.VFXTableHelper.FixedTableHelper; +import io.github.palexdev.virtualizedfx.table.VFXTableHelper.VariableTableHelper; +import io.github.palexdev.virtualizedfx.table.defaults.VFXDefaultTableRow; +import io.github.palexdev.virtualizedfx.utils.VFXCellsCache; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.CssMetaData; +import javafx.css.Styleable; +import javafx.css.StyleableProperty; +import javafx.css.StyleablePropertyFactory; +import javafx.geometry.Orientation; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Supplier; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class VFXTable extends Control> implements VFXContainer, VFXStyleable { + //================================================================================ + // Properties + //================================================================================ + private final VFXCellsCache> cache; + private final ListProperty items = new SimpleListProperty<>(FXCollections.observableArrayList()) { + @Override + public void set(ObservableList newValue) { + if (newValue == null) newValue = FXCollections.observableArrayList(); + super.set(newValue); + } + }; + private final FunctionProperty> rowFactory = new FunctionProperty<>() { + @Override + public void set(Function> newValue) { + if (newValue != null) { + newValue = newValue.andThen(r -> { + r.setTable(VFXTable.this); + return r; + }); + } + super.set(newValue); + } + + @Override + protected void invalidated() { + cache.setCellFactory(get()); + } + }; + private final ObservableList>> columns = FXCollections.observableArrayList(); + private final ReadOnlyObjectWrapper> helper = new ReadOnlyObjectWrapper<>() { + @Override + public void set(VFXTableHelper newValue) { + if (newValue == null) + throw new NullPointerException("Table helper cannot be null!"); + VFXTableHelper oldValue = get(); + if (oldValue != null) oldValue.dispose(); + super.set(newValue); + } + }; + private final FunctionProperty> helperFactory = new FunctionProperty<>(defaultHelperFactory()) { + @Override + public void set(Function> newValue) { + if (newValue == null) + throw new NullPointerException("Helper helper factory cannot be null!"); + super.set(newValue); + } + + @Override + protected void invalidated() { + VFXTableHelper helper = get().apply(getColumnsLayoutMode()); + setHelper(helper); + } + }; + private final DoubleProperty vPos = PropUtils.clampedDoubleProperty( + () -> 0.0, + () -> getHelper().maxVScroll() + ); + private final DoubleProperty hPos = PropUtils.clampedDoubleProperty( + () -> 0.0, + () -> getHelper().maxHScroll() + ); + private final VFXTableStateProperty state = new VFXTableStateProperty<>(VFXTableState.EMPTY); + private final ReadOnlyBooleanWrapper needsViewportLayout = new ReadOnlyBooleanWrapper(false); + + //================================================================================ + // Constructors + //================================================================================ + public VFXTable() { + cache = createCache(); + initialize(); + } + + public VFXTable(ObservableList items) { + cache = createCache(); + setItems(items); + initialize(); + } + + public VFXTable(ObservableList items, Collection>> columns) { + cache = createCache(); + setItems(items); + this.columns.setAll(columns); + initialize(); + } + + //================================================================================ + // Methods + //================================================================================ + private void initialize() { + getStyleClass().setAll(defaultStyleClasses()); + setDefaultBehaviorProvider(); + setHelper(getHelperFactory().apply(getColumnsLayoutMode())); + setRowFactory(defaultRowFactory()); + } + + // TODO add extra autosize? + + public int indexOf(VFXTableColumn column) { + if (column == null) return -1; + if (column.getIndex() < 0) column.setIndex(columns.indexOf(column)); + return column.getIndex(); + } + + protected VFXCellsCache> createCache() { + return new VFXCellsCache<>(null, getRowsCacheCapacity()); + } + + protected void update(VFXTableState state) { + setState(state); + } + + protected Function> defaultRowFactory() { + return VFXDefaultTableRow::new; + } + + protected Function> defaultHelperFactory() { + return mode -> mode == ColumnsLayoutMode.FIXED ? new FixedTableHelper<>(this) : new VariableTableHelper<>(this); + } + + /** + * Setter for the {@link #needsViewportLayoutProperty()}. + * This sets the property to true, causing the default skin to recompute the layout. + */ + public void requestViewportLayout() { + setNeedsViewportLayout(true); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + protected SkinBase buildSkin() { + return new VFXTableSkin<>(this); + } + + @Override + public Supplier> defaultBehaviorProvider() { + return () -> new VFXTableManager<>(this); + } + + @Override + public List defaultStyleClasses() { + return List.of("vfx-table"); + } + + //================================================================================ + // Delegate Methods + //================================================================================ + public VFXTable populateCache(boolean all) { + cache.populate(); + if (all) columns.forEach(VFXTableColumn::populateCache); + return this; + } + + public int rowsCacheSize() { + return cache.size(); + } + + public IntegerRange getRowsRange() {return getState().getRowsRange();} + + public IntegerRange getColumnsRange() {return getState().getColumnsRange();} + + public SequencedMap> getRowsByIndexUnmodifiable() {return getState().getRowsByIndexUnmodifiable();} + + public Map> getRowsByItemUnmodifiable() {return getState().getRowsByItemUnmodifiable();} + + @Override + public ReadOnlyDoubleProperty virtualMaxXProperty() { + return getHelper().virtualMaxXProperty(); + } + + @Override + public ReadOnlyDoubleProperty virtualMaxYProperty() { + return getHelper().virtualMaxYProperty(); + } + + @Override + public StyleableObjectProperty bufferSizeProperty() { + return rowsBufferSize; + } + + public void scrollVerticalBy(double pixels) { + getHelper().scrollBy(Orientation.VERTICAL, pixels); + } + + public void scrollHorizontalBy(double pixels) { + getHelper().scrollBy(Orientation.HORIZONTAL, pixels); + } + + public void scrollToPixelVertical(double pixel) { + getHelper().scrollToPixel(Orientation.VERTICAL, pixel); + } + + public void scrollToPixelHorizontal(double pixel) { + getHelper().scrollToPixel(Orientation.HORIZONTAL, pixel); + } + + public void scrollToRow(int index) { + getHelper().scrollToIndex(Orientation.VERTICAL, index); + } + + public void scrollToColumn(int index) { + getHelper().scrollToIndex(Orientation.HORIZONTAL, index); + } + + public void scrollToFirstRow() { + scrollToRow(0); + } + + public void scrollToLastRow() { + scrollToRow(size() - 1); + } + + public void scrollToFirstColumn() { + scrollToColumn(0); + } + + public void scrollToLastColumn() { + scrollToColumn(columns.size() - 1); + } + + //================================================================================ + // Styleable Properties + //================================================================================ + private final StyleableDoubleProperty rowHeight = new StyleableDoubleProperty( + StyleableProperties.ROW_HEIGHT, + this, + "rowHeight", + 32.0 + ); + private final StyleableSizeProperty columnsSize = new StyleableSizeProperty( + StyleableProperties.COLUMNS_SIZE, + this, + "columnsSize", + Size.of(100.0, 32.0) + ); + private final StyleableObjectProperty columnsLayoutMode = new StyleableObjectProperty<>( + StyleableProperties.COLUMNS_LAYOUT_MODE, + this, + "columnsLayoutMode", + ColumnsLayoutMode.FIXED + ) { + @Override + protected void invalidated() { + setHelper(getHelperFactory().apply(get())); + } + }; + private final StyleableObjectProperty columnsBufferSize = new StyleableObjectProperty<>( + StyleableProperties.COLUMNS_BUFFER_SIZE, + this, + "columnsBufferSize", + BufferSize.standard() + ); + private final StyleableObjectProperty rowsBufferSize = new StyleableObjectProperty<>( + StyleableProperties.ROWS_BUFFER_SIZE, + this, + "rowsBufferSize", + BufferSize.standard() + ); + private final StyleableDoubleProperty clipBorderRadius = new StyleableDoubleProperty( + StyleableProperties.CLIP_BORDER_RADIUS, + this, + "clipBorderRadius", + 0.0 + ); + private final StyleableIntegerProperty rowsCacheCapacity = new StyleableIntegerProperty( + StyleableProperties.ROWS_CACHE_CAPACITY, + this, + "rowsCacheCapacity", + 10 + ) { + @Override + protected void invalidated() { + cache.setCapacity(get()); + } + }; + + public double getRowHeight() { + return rowHeight.get(); + } + + public StyleableDoubleProperty rowHeightProperty() { + return rowHeight; + } + + public void setRowHeight(double rowHeight) { + this.rowHeight.set(rowHeight); + } + + public Size getColumnsSize() { + return columnsSize.get(); + } + + public StyleableSizeProperty columnsSizeProperty() { + return columnsSize; + } + + public void setColumnsSize(Size columnsSize) { + this.columnsSize.set(columnsSize); + } + + public void setColumnsSize(double w, double h) { + setColumnsSize(Size.of(w, h)); + } + + public void setColumnsWidth(double w) { + setColumnsSize(Size.of(w, getColumnsSize().getHeight())); + } + + public void setColumnsHeight(double h) { + setColumnsSize(Size.of(getColumnsSize().getWidth(), h)); + } + + public ColumnsLayoutMode getColumnsLayoutMode() { + return columnsLayoutMode.get(); + } + + public StyleableObjectProperty columnsLayoutModeProperty() { + return columnsLayoutMode; + } + + public void setColumnsLayoutMode(ColumnsLayoutMode columnsLayoutMode) { + this.columnsLayoutMode.set(columnsLayoutMode); + } + + public void switchColumnsLayoutMode() { + this.columnsLayoutMode.set(ColumnsLayoutMode.next(getColumnsLayoutMode())); + } + + public BufferSize getColumnsBufferSize() { + return columnsBufferSize.get(); + } + + public StyleableObjectProperty columnsBufferSizeProperty() { + return columnsBufferSize; + } + + public void setColumnsBufferSize(BufferSize columnsBufferSize) { + this.columnsBufferSize.set(columnsBufferSize); + } + + public BufferSize getRowsBufferSize() { + return rowsBufferSize.get(); + } + + public StyleableObjectProperty rowsBufferSizeProperty() { + return rowsBufferSize; + } + + public void setRowsBufferSize(BufferSize rowsBufferSize) { + this.rowsBufferSize.set(rowsBufferSize); + } + + public double getClipBorderRadius() { + return clipBorderRadius.get(); + } + + public StyleableDoubleProperty clipBorderRadiusProperty() { + return clipBorderRadius; + } + + public void setClipBorderRadius(double clipBorderRadius) { + this.clipBorderRadius.set(clipBorderRadius); + } + + public int getRowsCacheCapacity() { + return rowsCacheCapacity.get(); + } + + public StyleableIntegerProperty rowsCacheCapacityProperty() { + return rowsCacheCapacity; + } + + public void setRowsCacheCapacity(int rowsCacheCapacity) { + this.rowsCacheCapacity.set(rowsCacheCapacity); + } + + //================================================================================ + // CssMetaData + //================================================================================ + private static class StyleableProperties { + private static final StyleablePropertyFactory> FACTORY = new StyleablePropertyFactory<>(Control.getClassCssMetaData()); + private static final List> cssMetaDataList; + + private static final CssMetaData, Number> ROW_HEIGHT = + FACTORY.createSizeCssMetaData( + "-vfx-row-height", + VFXTable::rowHeightProperty, + 32.0 + ); + + private static final CssMetaData, Size> COLUMNS_SIZE = + new CssMetaData<>("-vfx-columns-size", SizeConverter.getInstance(), Size.of(100, 32)) { + @Override + public boolean isSettable(VFXTable styleable) { + return !styleable.columnsSizeProperty().isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(VFXTable styleable) { + return styleable.columnsSizeProperty(); + } + }; + + private static final CssMetaData, ColumnsLayoutMode> COLUMNS_LAYOUT_MODE = + FACTORY.createEnumCssMetaData( + ColumnsLayoutMode.class, + "-vfx-columns-layout-mode", + VFXTable::columnsLayoutModeProperty, + ColumnsLayoutMode.FIXED + ); + + private static final CssMetaData, BufferSize> COLUMNS_BUFFER_SIZE = + FACTORY.createEnumCssMetaData( + BufferSize.class, + "-vfx-columns-buffer-size", + VFXTable::columnsBufferSizeProperty, + BufferSize.standard() + ); + + private static final CssMetaData, BufferSize> ROWS_BUFFER_SIZE = + FACTORY.createEnumCssMetaData( + BufferSize.class, + "-vfx-rows-buffer-size", + VFXTable::rowsBufferSizeProperty, + BufferSize.standard() + ); + + private static final CssMetaData, Number> CLIP_BORDER_RADIUS = + FACTORY.createSizeCssMetaData( + "-vfx-clip-border-radius", + VFXTable::clipBorderRadiusProperty, + 0.0 + ); + + private static final CssMetaData, Number> ROWS_CACHE_CAPACITY = + FACTORY.createSizeCssMetaData( + "-vfx-rows-cache-capacity", + VFXTable::rowsCacheCapacityProperty, + 10 + ); + + static { + cssMetaDataList = StyleUtils.cssMetaDataList( + Control.getClassCssMetaData(), + ROW_HEIGHT, COLUMNS_SIZE, COLUMNS_LAYOUT_MODE, + COLUMNS_BUFFER_SIZE, ROWS_BUFFER_SIZE, CLIP_BORDER_RADIUS, + ROWS_CACHE_CAPACITY + ); + } + } + + @Override + protected List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + public static List> getClassCssMetaData() { + return StyleableProperties.cssMetaDataList; + } + + //================================================================================ + // Getters/Setters + //================================================================================ + protected VFXCellsCache> getCache() { + return cache; + } + + @Override + public ListProperty itemsProperty() { + return items; + } + + public Function> getRowFactory() { + return rowFactory.get(); + } + + public FunctionProperty> rowFactoryProperty() { + return rowFactory; + } + + public void setRowFactory(Function> rowFactory) { + this.rowFactory.set(rowFactory); + } + + public ObservableList>> getColumns() { + return columns; + } + + public VFXTableHelper getHelper() { + return helper.get(); + } + + /** + * Specifies the instance of the {@link VFXTableHelper} built by the {@link #helperFactoryProperty()}. + */ + public ReadOnlyObjectWrapper> helperProperty() { + return helper; + } + + public void setHelper(VFXTableHelper helper) { + this.helper.set(helper); + } + + public Function> getHelperFactory() { + return helperFactory.get(); + } + + /** + * Specifies the function used to build a {@link VFXTableHelper} instance. + */ + public FunctionProperty> helperFactoryProperty() { + return helperFactory; + } + + public void setHelperFactory(Function> helperFactory) { + this.helperFactory.set(helperFactory); + } + + @Override + public DoubleProperty vPosProperty() { + return vPos; + } + + @Override + public DoubleProperty hPosProperty() { + return hPos; + } + + public VFXTableState getState() { + return state.get(); + } + + public ReadOnlyObjectProperty> stateProperty() { + return state.getReadOnlyProperty(); + } + + protected void setState(VFXTableState state) { + this.state.set(state); + } + + public boolean isNeedsViewportLayout() { + return needsViewportLayout.get(); + } + + public ReadOnlyBooleanProperty needsViewportLayoutProperty() { + return needsViewportLayout.getReadOnlyProperty(); + } + + /** + * Specifies whether the viewport needs to compute the layout of its content. + *

+ * Since this is read-only, layout requests must be sent by using {@link #requestViewportLayout()}. + */ + protected void setNeedsViewportLayout(boolean needsViewportLayout) { + this.needsViewportLayout.set(needsViewportLayout); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableColumn.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableColumn.java new file mode 100644 index 0000000..a98d7f1 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableColumn.java @@ -0,0 +1,234 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.properties.functional.FunctionProperty; +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableIntegerProperty; +import io.github.palexdev.mfxcore.behavior.BehaviorBase; +import io.github.palexdev.mfxcore.controls.Labeled; +import io.github.palexdev.mfxcore.utils.fx.StyleUtils; +import io.github.palexdev.virtualizedfx.base.VFXStyleable; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.table.defaults.VFXSimpleTableCell; +import io.github.palexdev.virtualizedfx.utils.VFXCellsCache; +import javafx.beans.property.*; +import javafx.collections.ObservableList; +import javafx.css.CssMetaData; +import javafx.css.Styleable; +import javafx.css.StyleablePropertyFactory; +import javafx.scene.Node; + +import java.util.List; +import java.util.function.Function; + +public abstract class VFXTableColumn> extends Labeled>> implements VFXStyleable { + //================================================================================ + // Properties + //================================================================================ + private final VFXCellsCache cache; + private final ReadOnlyObjectWrapper> table = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyIntegerWrapper index = new ReadOnlyIntegerWrapper(-1); + private final FunctionProperty cellFactory = new FunctionProperty<>(defaultCellFactory()) { + @Override + protected void invalidated() { + Function newFn = get(); + cache.setCellFactory(newFn); + onCellFactoryChanged(newFn); + } + }; + + //================================================================================ + // Constructors + //================================================================================ + public VFXTableColumn() { + cache = createCache(); + initialize(); + } + + public VFXTableColumn(String text) { + super(text); + cache = createCache(); + initialize(); + } + + public VFXTableColumn(String text, Node graphic) { + super(text, graphic); + cache = createCache(); + initialize(); + } + + //================================================================================ + // Static Methods + //================================================================================ + // TODO documentation is important here, as well as mentioning the why in VFXTable + @SuppressWarnings({"rawtypes", "unchecked"}) + public static void swapColumns(ObservableList>> columns, int i, int j) { + VFXTableColumn[] arr = columns.toArray(VFXTableColumn[]::new); + VFXTableColumn tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + columns.setAll(arr); + } + + public static void swapColumns(VFXTable table, int i, int j) { + swapColumns(table.getColumns(), i, j); + } + + //================================================================================ + // Methods + //================================================================================ + private void initialize() { + getStyleClass().setAll(defaultStyleClasses()); + setDefaultBehaviorProvider(); + } + + public void resize(double width) { + // TODO this way, otherwise always layout even if width is the same in manager +/* VFXTable table = getTable(); + if (table == null) { + setPrefWidth(width); + return; + } + double oldW = table.getHelper().getColumnWidth(this); + setPrefWidth(width); + table.getBehavior().onColumnWidthChanged(this, oldW);*/ + + setPrefWidth(width); + VFXTable table = getTable(); + if (table == null) return; + table.getBehavior().onColumnWidthChanged(this); + } + + protected VFXCellsCache createCache() { + return new VFXCellsCache<>(getCellFactory(), getCellsCacheCapacity()); + } + + @SuppressWarnings("unchecked") + protected Function defaultCellFactory() { + return t -> (C) new VFXSimpleTableCell<>(t, t1 -> t1); + } + + @SuppressWarnings("unchecked") + protected void onCellFactoryChanged(Function newFactory) { + VFXTable table = getTable(); + if (table == null) return; + VFXTableManager manager = table.getBehavior(); + manager.onCellFactoryChanged((VFXTableColumn>) this); + cache.clear(); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + public List defaultStyleClasses() { + return List.of("vfx-column"); + } + + //================================================================================ + // Styleable Properties + //================================================================================ + private final StyleableIntegerProperty cellsCacheCapacity = new StyleableIntegerProperty( + StyleableProperties.CELLS_CACHE_CAPACITY, + this, + "cellsCacheCapacity", + 10 + ) { + @Override + protected void invalidated() { + cache.setCapacity(get()); + } + }; + + public int getCellsCacheCapacity() { + return cellsCacheCapacity.get(); + } + + public StyleableIntegerProperty cellsCacheCapacityProperty() { + return cellsCacheCapacity; + } + + public void setCellsCacheCapacity(int cellsCacheCapacity) { + this.cellsCacheCapacity.set(cellsCacheCapacity); + } + + //================================================================================ + // CssMetaData + //================================================================================ + private static class StyleableProperties { + private static final StyleablePropertyFactory> FACTORY = new StyleablePropertyFactory<>(Labeled.getClassCssMetaData()); + private static final List> cssMetaDataList; + + private static final CssMetaData, Number> CELLS_CACHE_CAPACITY = + FACTORY.createSizeCssMetaData( + "-vfx-cells-cache-capacity", + VFXTableColumn::cellsCacheCapacityProperty, + 10 + ); + + static { + cssMetaDataList = StyleUtils.cssMetaDataList( + Labeled.getClassCssMetaData(), + CELLS_CACHE_CAPACITY + ); + } + } + + public static List> getClassCssMetaData() { + return StyleableProperties.cssMetaDataList; + } + + @Override + public List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + //================================================================================ + // Getters/Setters + //================================================================================ + protected VFXCellsCache cache() { + return cache; + } + + public void populateCache() { + cache.populate(); + } + + public int cacheSize() { + return cache.size(); + } + + public VFXTable getTable() { + return table.get(); + } + + public ReadOnlyObjectProperty> tableProperty() { + return table.getReadOnlyProperty(); + } + + protected void setTable(VFXTable table) { + this.table.set(table); + } + + public int getIndex() { + return index.get(); + } + + public ReadOnlyIntegerProperty indexProperty() { + return index.getReadOnlyProperty(); + } + + protected void setIndex(int index) { + this.index.set(index); + } + + public Function getCellFactory() { + return cellFactory.get(); + } + + public FunctionProperty cellFactoryProperty() { + return cellFactory; + } + + public void setCellFactory(Function cellFactory) { + this.cellFactory.set(cellFactory); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableHelper.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableHelper.java new file mode 100644 index 0000000..870bc83 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableHelper.java @@ -0,0 +1,628 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.beans.Position; +import io.github.palexdev.mfxcore.base.beans.Size; +import io.github.palexdev.mfxcore.base.beans.range.DoubleRange; +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.mfxcore.base.beans.range.NumberRange; +import io.github.palexdev.mfxcore.base.properties.PositionProperty; +import io.github.palexdev.mfxcore.base.properties.range.IntegerRangeProperty; +import io.github.palexdev.mfxcore.builders.bindings.DoubleBindingBuilder; +import io.github.palexdev.mfxcore.builders.bindings.ObjectBindingBuilder; +import io.github.palexdev.mfxcore.utils.NumberUtils; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.utils.Utils; +import io.github.palexdev.virtualizedfx.utils.VFXCellsCache; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyDoubleWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.collections.ObservableList; +import javafx.geometry.Orientation; +import javafx.scene.Node; + +import java.util.*; + +public interface VFXTableHelper { + + int firstColumn(); + + int lastColumn(); + + int visibleColumns(); + + int totalColumns(); + + ReadOnlyObjectProperty> columnsRangeProperty(); + + default IntegerRange columnsRange() { + return (IntegerRange) columnsRangeProperty().get(); + } + + int firstRow(); + + int lastRow(); + + int visibleRows(); + + int totalRows(); + + ReadOnlyObjectProperty> rowsRangeProperty(); + + default IntegerRange rowsRange() { + return (IntegerRange) rowsRangeProperty().get(); + } + + double maxHScroll(); + + double maxVScroll(); + + ReadOnlyDoubleProperty virtualMaxXProperty(); + + default double getVirtualMaxX() { + return virtualMaxXProperty().get(); + } + + ReadOnlyDoubleProperty virtualMaxYProperty(); + + default double getVirtualMaxY() { + return virtualMaxYProperty().get(); + } + + ReadOnlyObjectProperty viewportPositionProperty(); + + default Position getViewportPosition() { + return viewportPositionProperty().get(); + } + + double getColumnWidth(VFXTableColumn column); + + double getColumnPos(int layoutIdx, VFXTableColumn column); + + void layoutColumn(int layoutIdx, VFXTableColumn column); + + void layoutRow(int layoutIdx, VFXTableRow row); + + void layoutCell(int layoutIdx, Node node); + + boolean isInViewport(VFXTableColumn column); + + VFXTable getTable(); + + default int visibleCells() { + // TODO check the computation for the two layout mode + int nColumns = visibleColumns(); + int nRows = visibleRows(); + return nColumns * nRows; + } + + default int totalCells() { + // TODO check this too + int nColumns = columnsRange().diff() + 1; + int nRows = rowsRange().diff() + 1; + return nColumns * nRows; + } + + default void invalidatePos() { + VFXTable table = getTable(); + table.setVPos(table.getVPos()); + table.setHPos(table.getHPos()); + } + + default T indexToItem(int index) { + return getTable().getItems().get(index); + } + + default VFXTableRow indexToRow(int index) { + T item = indexToItem(index); + return itemToRow(item); + } + + default VFXTableRow itemToRow(T item) { + VFXCellsCache> cache = getTable().getCache(); + Optional> opt = cache.tryTake(); + opt.ifPresent(c -> c.updateItem(item)); + return opt.orElseGet(() -> getTable().getRowFactory().apply(item)); + } + + default double getViewportHeight() { + VFXTable table = getTable(); + return Math.max(0, table.getHeight() - table.getColumnsSize().getHeight()); + } + + default void scrollBy(Orientation orientation, double pixels) { + VFXTable table = getTable(); + if (orientation == Orientation.HORIZONTAL) { + table.setHPos(table.getHPos() + pixels); + } else { + table.setVPos(table.getVPos() + pixels); + } + } + + default void scrollToPixel(Orientation orientation, double pixel) { + VFXTable table = getTable(); + if (orientation == Orientation.HORIZONTAL) { + table.setHPos(pixel); + } else { + table.setVPos(pixel); + } + } + + void scrollToIndex(Orientation orientation, int index); + + default void dispose() {} + + abstract class AbstractHelper implements VFXTableHelper { + protected VFXTable table; + protected final IntegerRangeProperty columnsRange = new IntegerRangeProperty(); + protected final IntegerRangeProperty rowsRange = new IntegerRangeProperty(); + protected final ReadOnlyDoubleWrapper virtualMaxX = new ReadOnlyDoubleWrapper(); + protected final ReadOnlyDoubleWrapper virtualMaxY = new ReadOnlyDoubleWrapper(); + protected final PositionProperty viewportPosition = new PositionProperty(); + + public AbstractHelper(VFXTable table) { + this.table = table; + initBindings(); + } + + protected abstract void initBindings(); + + protected final boolean isLastColumn(VFXTableColumn column) { + ObservableList>> columns = getTable().getColumns(); + if (columns.isEmpty()) return false; + return columns.getLast() == column; + } + + @Override + public int lastColumn() { + return columnsRange().getMax(); + } + + @Override + public ReadOnlyObjectProperty> columnsRangeProperty() { + return columnsRange; + } + + @Override + public int firstRow() { + return NumberUtils.clamp( + (int) Math.floor(table.getVPos() / table.getRowHeight()), + 0, + table.size() - 1 + ); + } + + @Override + public int lastRow() { + return rowsRange().getMax(); + } + + @Override + public int visibleRows() { + double height = table.getRowHeight(); + return height > 0 ? + (int) Math.ceil(getViewportHeight() / height) : + 0; + } + + @Override + public int totalRows() { + int visible = visibleRows(); + return visible == 0 ? 0 : Math.min(visible + table.getRowsBufferSize().val() * 2, table.size()); + } + + @Override + public ReadOnlyObjectProperty> rowsRangeProperty() { + return rowsRange; + } + + @Override + public double maxHScroll() { + return Math.max(0, getVirtualMaxX() - table.getWidth()); + } + + @Override + public double maxVScroll() { + return Math.max(0, getVirtualMaxY() - getViewportHeight()); + } + + @Override + public ReadOnlyDoubleProperty virtualMaxXProperty() { + return virtualMaxX; + } + + @Override + public ReadOnlyDoubleProperty virtualMaxYProperty() { + return virtualMaxY; + } + + @Override + public ReadOnlyObjectProperty viewportPositionProperty() { + return viewportPosition; + } + + @Override + public VFXTable getTable() { + return table; + } + + @Override + public void dispose() { + table = null; + } + } + + class FixedTableHelper extends AbstractHelper { + + public FixedTableHelper(VFXTable table) { + super(table); + } + + @Override + protected void initBindings() { + columnsRange.bind(ObjectBindingBuilder.build() + .setMapper(() -> { + if (table.getWidth() <= 0) return Utils.INVALID_RANGE; + int needed = totalColumns(); + if (needed == 0) return Utils.INVALID_RANGE; + + int start = Math.max(0, firstColumn() - table.getColumnsBufferSize().val()); + int end = Math.min(table.getColumns().size() - 1, start + needed - 1); + if (end - start + 1 < needed) start = Math.max(0, end - needed + 1); + return IntegerRange.of(start, end); + }) + .addSources(table.getColumns()) + .addSources(table.widthProperty()) + .addSources(table.hPosProperty()) + .addSources(table.columnsBufferSizeProperty()) + .addSources(table.columnsSizeProperty()) + .get() + ); + rowsRange.bind(ObjectBindingBuilder.build() + .setMapper(() -> { + if (getViewportHeight() <= 0) return Utils.INVALID_RANGE; + int needed = totalRows(); + if (needed == 0) return Utils.INVALID_RANGE; + + int start = Math.max(0, firstRow() - table.getRowsBufferSize().val()); + int end = Math.min(table.size() - 1, start + needed - 1); + if (end - start + 1 < needed) start = Math.max(0, end - needed + 1); + return IntegerRange.of(start, end); + }) + .addSources(table.heightProperty(), table.columnsSizeProperty()) + .addSources(table.vPosProperty()) + .addSources(table.rowsBufferSizeProperty()) + .addSources(table.sizeProperty(), table.rowHeightProperty()) + .get() + ); + + virtualMaxX.bind(DoubleBindingBuilder.build() + .setMapper(() -> Math.max(table.getWidth(), table.getColumns().size() * table.getColumnsSize().getWidth())) + .addSources(table.widthProperty(), table.getColumns(), table.columnsSizeProperty()) + .get() + ); + virtualMaxY.bind(DoubleBindingBuilder.build() + .setMapper(() -> table.getColumns().isEmpty() ? 0.0 : table.size() * table.getRowHeight()) + .addSources(table.getColumns(), table.columnsSizeProperty()) + .addSources(table.sizeProperty(), table.rowHeightProperty()) + .get() + ); + + viewportPosition.bind(ObjectBindingBuilder.build() + .setMapper(() -> { + double x = 0; + double y = 0; + IntegerRange rowsRange = rowsRange(); + IntegerRange columnsRange = columnsRange(); + + if (!Utils.INVALID_RANGE.equals(rowsRange)) { + double cHeight = table.getRowHeight(); + IntegerRange rRangeToFirstVisible = IntegerRange.of(rowsRange.getMin(), firstRow()); + double rPixelsToFirst = rRangeToFirstVisible.diff() * cHeight; + double rVisibleAmount = table.getVPos() % cHeight; + y = -(rPixelsToFirst + rVisibleAmount); + } + if (!Utils.INVALID_RANGE.equals(columnsRange)) { + double cWidth = table.getColumnsSize().getWidth(); + IntegerRange cRangeToFirstVisible = IntegerRange.of(columnsRange.getMin(), firstColumn()); + double cPixelsToFirst = cRangeToFirstVisible.diff() * cWidth; + double cVisibleAmount = table.getHPos() % cWidth; + x = -(cPixelsToFirst + cVisibleAmount); + } + return Position.of(x, y); + }) + .addSources(table.vPosProperty(), table.hPosProperty()) + .addSources(table.rowHeightProperty(), table.columnsSizeProperty()) + .get() + ); + } + + @Override + public int firstColumn() { + return NumberUtils.clamp( + (int) Math.floor(table.getHPos() / table.getColumnsSize().getWidth()), + 0, + table.getColumns().size() - 1 + ); + } + + @Override + public int visibleColumns() { + double width = table.getColumnsSize().getWidth(); + return width > 0 ? + (int) Math.ceil(table.getWidth() / width) : + 0; + } + + @Override + public int totalColumns() { + int visible = visibleColumns(); + return visible == 0 ? 0 : Math.min(visible + table.getColumnsBufferSize().val() * 2, table.getColumns().size()); + } + + @Override + public double getColumnWidth(VFXTableColumn column) { + VFXTable table = getTable(); + double width = table.getColumnsSize().getWidth(); + if (!isLastColumn(column)) return width; + + IntegerRange columnsRange = table.getState().getColumnsRange(); + return Math.max(width, table.getWidth() - columnsRange.diff() * width); + } + + @Override + public double getColumnPos(int layoutIdx, VFXTableColumn column) { + return table.getColumnsSize().getWidth() * layoutIdx; + } + + @Override + public void layoutColumn(int layoutIdx, VFXTableColumn column) { + Size size = getTable().getColumnsSize(); + double x = getColumnPos(layoutIdx, column); + double w = getColumnWidth(column); + double h = size.getHeight(); + column.resizeRelocate(x, 0, w, h); + } + + @Override + public void layoutRow(int layoutIdx, VFXTableRow row) { + double w = getVirtualMaxX(); + double h = getTable().getRowHeight(); + double y = layoutIdx * h; + row.resizeRelocate(0, y, w, h); + } + + @Override + public void layoutCell(int layoutIdx, Node node) { + double w = getTable().getColumnsSize().getWidth(); + double h = getTable().getRowHeight(); + double x = layoutIdx * w; + node.resizeRelocate(x, 0, w, h); + } + + @Override + public boolean isInViewport(VFXTableColumn column) { + if (column.getTable() == null || column.getScene() == null || column.getParent() == null) return false; + VFXTableState state = table.getState(); + if (state == VFXTableState.EMPTY) return false; + int index = column.getIndex(); + return index >= 0 && IntegerRange.inRangeOf(index, state.getColumnsRange()); + } + + @Override + public void scrollToIndex(Orientation orientation, int index) { + if (orientation == Orientation.HORIZONTAL) { + table.setHPos(table.getColumnsSize().getWidth() * index); + } else { + table.setVPos(table.getRowHeight() * index); + } + } + } + + class VariableTableHelper extends AbstractHelper { + private ColumnsSizeCache sizeCache; + private SequencedMap xPosCache = new TreeMap<>() { + { + put(0, 0.0); + } + }; + private Map, Boolean> inViewportCache = new HashMap<>(); + + public VariableTableHelper(VFXTable table) { + super(table); + } + + protected double computeColumnWidth(VFXTableColumn column, boolean isLast) { + double minW = table.getColumnsSize().getWidth(); + double prefW = Math.max(column.prefWidth(-1), minW); + if (table.getColumns().size() == 1) return Math.max(prefW, table.getWidth()); + if (!isLast) return prefW; + + double partialW = sizeCache.getPartialWidth(); + return Math.max(prefW, table.getWidth() - partialW); + } + + @Override + protected void initBindings() { + sizeCache = new ColumnsSizeCache<>(table) + .setWidthFunction(this::computeColumnWidth) + .init(); + + columnsRange.bind(ObjectBindingBuilder.build() + .setMapper(() -> { + ObservableList>> columns = table.getColumns(); + if (columns.isEmpty()) return Utils.INVALID_RANGE; + return IntegerRange.of(0, columns.size() - 1); + }) + .addSources(table.getColumns()) + .get() + ); + rowsRange.bind(ObjectBindingBuilder.build() + .setMapper(() -> { + if (getViewportHeight() <= 0) return Utils.INVALID_RANGE; + int needed = totalRows(); + if (needed == 0) return Utils.INVALID_RANGE; + + int start = Math.max(0, firstRow() - table.getRowsBufferSize().val()); + int end = Math.min(table.size() - 1, start + needed - 1); + if (end - start + 1 < needed) start = Math.max(0, end - needed + 1); + return IntegerRange.of(start, end); + }) + .addSources(table.heightProperty(), table.columnsSizeProperty()) + .addSources(table.vPosProperty()) + .addSources(table.rowsBufferSizeProperty()) + .addSources(table.sizeProperty(), table.rowHeightProperty()) + .get() + ); + + virtualMaxX.bind(sizeCache); + virtualMaxY.bind(DoubleBindingBuilder.build() + .setMapper(() -> table.getColumns().isEmpty() ? 0.0 : table.size() * table.getRowHeight()) + .addSources(table.getColumns(), table.columnsSizeProperty()) + .addSources(table.sizeProperty(), table.rowHeightProperty()) + .get() + ); + + viewportPosition.bind(ObjectBindingBuilder.build() + .setMapper(() -> { + double x = 0; + double y = 0; + IntegerRange rowsRange = rowsRange(); + IntegerRange columnsRange = columnsRange(); + + if (!Utils.INVALID_RANGE.equals(rowsRange)) { + double cHeight = table.getRowHeight(); + IntegerRange rRangeToFirstVisible = IntegerRange.of(rowsRange.getMin(), firstRow()); + double rPixelsToFirst = rRangeToFirstVisible.diff() * cHeight; + double rVisibleAmount = table.getVPos() % cHeight; + y = -(rPixelsToFirst + rVisibleAmount); + } + if (!Utils.INVALID_RANGE.equals(columnsRange)) { + x = -table.getHPos(); + } + return Position.of(x, y); + }) + .addSources(table.vPosProperty(), table.hPosProperty()) + .addSources(table.rowHeightProperty(), table.columnsSizeProperty()) + .get() + ); + } + + @Override + public int firstColumn() { + return 0; + } + + @Override + public int visibleColumns() { + return table.getColumns().size(); + } + + @Override + public int totalColumns() { + return table.getColumns().size(); + } + + @Override + public double getColumnWidth(VFXTableColumn column) { + return sizeCache.getColumnWidth(column); + } + + @Override + public double getColumnPos(int layoutIdx, VFXTableColumn column) { + double pos = xPosCache.get(layoutIdx); + xPosCache.put(layoutIdx + 1, pos + getColumnWidth(column)); + return pos; + } + + @Override + public void layoutColumn(int layoutIdx, VFXTableColumn column) { + // Invalidate before re-computing since inViewport() uses the cache by default + inViewportCache.remove(column); + // It's crucial for this instruction to always run, because this also incrementally builds the xPosCache + double x = getColumnPos(layoutIdx, column); + if (!isInViewport(column)) { + column.setVisible(false); + return; + } + Size size = getTable().getColumnsSize(); + double w = getColumnWidth(column); + double h = size.getHeight(); + column.resizeRelocate(x, 0, w, h); + column.setVisible(true); + } + + @Override + public void layoutRow(int layoutIdx, VFXTableRow row) { + double w = Math.max(getVirtualMaxX(), table.getWidth()); + double h = getTable().getRowHeight(); + double y = layoutIdx * h; + row.resizeRelocate(0, y, w, h); + } + + @Override + public void layoutCell(int layoutIdx, Node node) { + ObservableList>> columns = table.getColumns(); + VFXTableColumn> column = columns.get(layoutIdx); + if (!isInViewport(column)) { + node.setVisible(false); + return; + } + double w = getColumnWidth(column); + double h = getTable().getRowHeight(); + double x = xPosCache.get(layoutIdx); + node.resizeRelocate(x, 0, w, h); + node.setVisible(true); + } + + @Override + public boolean isInViewport(VFXTableColumn column) { + // Use cache if available + Boolean val = inViewportCache.get(column); + if (val != null) return val; + + // Otherwise compute + if (column.getTable() == null || column.getScene() == null || column.getParent() == null || column.getIndex() < 0) { + val = false; + } else { + try { + int index = column.getIndex(); + if (!xPosCache.containsKey(index)) return false; + double tableW = table.getWidth(); + double hPos = table.getHPos(); + DoubleRange viewBounds = DoubleRange.of(hPos, hPos + tableW); + double columnX = xPosCache.get(index); + double columnW = getColumnWidth(column); + val = (columnX + columnW >= viewBounds.getMin()) && (columnX <= viewBounds.getMax()); + } catch (Exception ex) { + val = false; + } + } + // Cache the result before returning + inViewportCache.put(column, val); + return val; + } + + @Override + public void scrollToIndex(Orientation orientation, int index) { + if (orientation == Orientation.HORIZONTAL) { + try { + VFXTableColumn> column = table.getColumns().get(index); + table.setHPos(column.getBoundsInParent().getMinX()); + } catch (Exception ignored) {} + } else { + table.setVPos(table.getRowHeight() * index); + } + } + + @Override + public void dispose() { + sizeCache.dispose(); + xPosCache.clear(); + inViewportCache.clear(); + sizeCache = null; + xPosCache = null; + inViewportCache = null; + super.dispose(); + } + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableManager.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableManager.java new file mode 100644 index 0000000..bc86367 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableManager.java @@ -0,0 +1,508 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.mfxcore.behavior.BehaviorBase; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode; +import io.github.palexdev.virtualizedfx.enums.GeometryChangeType; +import io.github.palexdev.virtualizedfx.utils.ExcludingRange; +import io.github.palexdev.virtualizedfx.utils.Utils; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Orientation; +import javafx.scene.Node; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; + +public class VFXTableManager extends BehaviorBase> { + //================================================================================ + // Properties + //================================================================================ + protected boolean invalidatingPos = false; + protected boolean wasGeometryChange = false; + protected boolean isTableBigger = false; + + //================================================================================ + // Constructors + //================================================================================ + public VFXTableManager(VFXTable table) { + super(table); + } + + //================================================================================ + // Methods + //================================================================================ + + protected void onGeometryChanged(GeometryChangeType gct) { + onGeometryChanged(gct, -1, -1); + } + + protected void onGeometryChanged(GeometryChangeType gct, double oldV, double newV) { + invalidatingPos = true; + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + if (!tableFactorySizeCheck()) return; + + // Ensure positions are correct! + helper.invalidatePos(); + + IntegerRange rowsRange = helper.rowsRange(); + IntegerRange columnsRange = helper.columnsRange(); + if (!rangeCheck(columnsRange, true, true)) return; + + // Compute the new state + VFXTableState newState = new VFXTableState<>(table, rowsRange, columnsRange); + newState.setColumnsChanged(table.getState()); + moveReuseCreateAlgorithm(rowsRange, columnsRange, newState); + + if (disposeCurrent()) newState.setRowsChanged(true); + table.update(newState); + invalidatingPos = false; + + // TODO EXPERIMENTAL + if (gct == GeometryChangeType.WIDTH && !newState.isLayoutNeeded()) { + wasGeometryChange = true; + isTableBigger = (newV > oldV); + VFXTableColumn> last = table.getColumns().getLast(); + if (!last.isVisible() || table.getWidth() > last.getBoundsInParent().getMaxX()) { + onColumnWidthChanged(last); + } else if (last.getWidth() > table.getColumnsSize().getWidth()) { + onColumnWidthChanged(last); + } + wasGeometryChange = false; + isTableBigger = false; + } + } + + // TODO EXPERIMENTAL + protected void onColumnWidthChanged(VFXTableColumn column) { + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + ColumnsLayoutMode layoutMode = table.getColumnsLayoutMode(); + VFXTableState state = table.getState(); + if (state == VFXTableState.EMPTY || + layoutMode == ColumnsLayoutMode.FIXED && + !wasGeometryChange + ) return; + + + double oldW = column.getWidth(); + double newW = helper.getColumnWidth(column); + //if (newW == oldW) return; // NO-OP + + if (layoutMode == ColumnsLayoutMode.FIXED) { + helper.layoutColumn(state.getColumnsRange().diff(), column); + state.getRowsByIndex().values().forEach(r -> { + r.resize(table.getVirtualMaxX(), r.getHeight()); + r.partialLayout(column, oldW, newW); + }); + return; + } + + ObservableList>> columns = table.getColumns(); + int startIndex = wasGeometryChange ? 0 : table.indexOf(column); + Function, Boolean> check = (wasGeometryChange && isTableBigger) ? Node::isVisible : helper::isInViewport; + BiConsumer> action = wasGeometryChange ? + (i, c) -> {if (columns.getLast() == c) helper.layoutColumn(i, c);} : + helper::layoutColumn; + BiConsumer> elseAction = (wasGeometryChange && isTableBigger) ? helper::layoutColumn : (i, c) -> c.setVisible(false); + VFXTableColumn startColumn = column; + for (int i = startIndex; i < columns.size(); i++) { + VFXTableColumn> c = columns.get(i); + if (check.apply(c)) { + action.accept(i, c); + continue; + } + elseAction.accept(i, c); + if (wasGeometryChange && startColumn == column) startColumn = c; + } + + for (VFXTableRow row : state.getRowsByIndex().values()) { + row.resize(table.getVirtualMaxX(), row.getHeight()); + row.partialLayout(startColumn, oldW, newW); + } + } + + protected void onColumnsChanged(ListChangeListener.Change> change) { + VFXTable table = getNode(); + + // Update columns' table reference + if (change == null) { + table.getColumns().forEach(c -> c.setTable(table)); + return; + } + + // A setAll operation may end up adding the same columns as before (or even just some of them) + // Which means that both wasRemoved and wasAdded computation will run, we don't want that here. + // Simply handle removals after ensuring that a column that "was removed" is not still in the list + Set> rm = new HashSet<>(); + while (change.next()) { + if (change.wasRemoved()) rm.addAll(change.getRemoved()); + if (change.wasAdded()) { + for (VFXTableColumn c : change.getAddedSubList()) { + if (rm.contains(c)) { + rm.remove(c); + continue; + } + c.setTable(table); + } + } + } + rm.forEach(c -> { + c.setTable(null); + c.setIndex(-1); + }); + + VFXTableHelper helper = table.getHelper(); + invalidatingPos = true; + helper.invalidatePos(); // Changes to the columns' list may invalidate the hPos + + // Compute the new ranges + IntegerRange columnsRange = helper.columnsRange(); + IntegerRange rowsRange = helper.rowsRange(); + if (!rangeCheck(columnsRange, true, true)) { + return; // If invalid (no columns), dispose current and set EMPTY state + } + + VFXTableState state = table.getState(); + VFXTableState newState = new VFXTableState<>(table, rowsRange, columnsRange, state.getRows()); + newState.setColumnsChanged(true); + if (!Utils.INVALID_RANGE.equals(rowsRange)) { + for (Integer idx : rowsRange) { + VFXTableRow row = newState.getRows().get(idx); + if (row == null) { + row = helper.indexToRow(idx); + newState.addRow(idx, row); + newState.setRowsChanged(true); + } + row.updateColumns(columnsRange, true); + } + } + table.update(newState); + invalidatingPos = false; + } + + protected void onItemsChanged() { + invalidatingPos = true; + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + + helper.invalidatePos(); + + // If the table is now empty, then set empty state + if (!tableFactorySizeCheck()) return; + + // Compute rows ranges and new state + VFXTableState current = table.getState(); + IntegerRange rowsRange = helper.rowsRange(); + ExcludingRange eRange = ExcludingRange.of(rowsRange); + VFXTableState newState = new VFXTableState<>(table, rowsRange, current.getColumnsRange()); + + // First update by index + for (Integer idx : rowsRange) { + T item = helper.indexToItem(idx); + VFXTableRow row = current.removeRow(item); + if (row != null) { + eRange.exclude(idx); + row.updateIndex(idx); + newState.addRow(idx, item, row); + } + } + + // Process remaining with the "remaining" algorithm + remainingAlgorithm(eRange, newState); + + if (disposeCurrent()) newState.setRowsChanged(true); + table.update(newState); + if (!newState.haveRowsChanged()) table.requestViewportLayout(); + invalidatingPos = false; + } + + protected void onPositionChanged(Orientation axis) { + if (invalidatingPos) return; + VFXTable table = getNode(); + VFXTableState state = table.getState(); + if (state == VFXTableState.EMPTY) return; + + VFXTableHelper helper = table.getHelper(); + IntegerRange columnsRange = helper.columnsRange(); + + // If the scroll was alongside the x-axis, then the columns range may change + // We have two cases here: layout mode fixed and variable. + // However, in variable mode the range is always the same, no update will occur in the rows + if (axis == Orientation.HORIZONTAL) { + if (table.getColumnsLayoutMode() == ColumnsLayoutMode.VARIABLE) return; + + // If the range didn't change, don't update + if (state.getColumnsRange().equals(columnsRange)) return; + + // Here rather than moving the rows, we use the same map of the old state and just tell the rows to update + VFXTableState newState = new VFXTableState<>(table, state.getRowsRange(), columnsRange, state.getRows()); + newState.setColumnsChanged(true); + state.getRowsByIndex().values().forEach(r -> r.updateColumns(columnsRange, false)); + table.update(newState); + return; + } + + // If the scroll was alongside the y-axis, then we use the classic moveReuseCreate algorithm + // If the range didn't change, then do nothing + IntegerRange rowsRange = helper.rowsRange(); + if (state.getRowsRange().equals(rowsRange)) return; + VFXTableState newState = new VFXTableState<>(table, rowsRange, columnsRange); + moveReuseCreateAlgorithm(rowsRange, columnsRange, newState); + + if (disposeCurrent()) newState.setRowsChanged(true); + table.update(newState); + if (!newState.haveRowsChanged()) table.requestViewportLayout(); + } + + protected void onRowFactoryChanged() { + VFXTable table = getNode(); + VFXTableState state = table.getState(); + + // First check basic properties to ensure we can generate a valid state + if (!tableFactorySizeCheck()) { + // Make sure to also invalidate the cache! + table.getCache().clear(); + return; + } + + // At this point, we can generate a new state + VFXTableHelper helper = table.getHelper(); + Function> rf = table.getRowFactory(); + IntegerRange rowsRange = helper.rowsRange(); + IntegerRange columnsRange = helper.columnsRange(); + + VFXTableState newState = new VFXTableState<>(table, rowsRange, columnsRange); + newState.setRowsChanged(true); + + // Iterate over the rows range and generate a row with the new factory for each index/item + // The new rows will copy the state of the previous row at the same index (expect if the old state is EMPTY or empty) + for (Integer idx : rowsRange) { + T item = table.getItems().get(idx); + VFXTableRow row = rf.apply(item); + if (state != VFXTableState.EMPTY && !state.isEmpty()) { + row.copyState(state.getRows().get(idx)); + } else { + row.updateIndex(idx); + row.updateColumns(columnsRange, false); + } + newState.addRow(idx, item, row); + } + + disposeCurrent(); + table.getCache().clear(); + table.update(newState); + } + + protected void onCellFactoryChanged(VFXTableColumn> column) { + VFXTable table = getNode(); + VFXTableState state = table.getState(); + boolean updated = false; + for (VFXTableRow row : state.getRowsByIndex().values()) { + updated = row.replaceCells(column) || updated; // Order here is crucial because || operator is 'short circuit' + } + if (updated) table.update(state.clone()); + } + + protected void onRowHeightChanged() { + invalidatingPos = true; + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + + // Ensure positions are correct + helper.invalidatePos(); + + if (!tableFactorySizeCheck()) return; + + // Compute the new state with the intersection algorithm + VFXTableState newState = intersectionAlgorithm(); + + if (disposeCurrent()) newState.setRowsChanged(true); + table.update(newState); + invalidatingPos = false; + } + + protected void onColumnsSizeChanged() { + invalidatingPos = true; + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + + // I believe such change could potentially mess with positions, even if ranges do not change + // So, just to make sure, let's invalidate them + helper.invalidatePos(); + + // Get the current state, as well as both the rows and columns ranges + VFXTableState state = table.getState(); + IntegerRange rowsRange = helper.rowsRange(); + IntegerRange columnsRange = helper.columnsRange(); + + // Before proceeding, make sure to check the ranges are valid + // This essentially also checks that the current state is not EMPTY + if (!rangeCheck(columnsRange, true, true)) return; + + // Three possible cases + // 1) The rows range changed: delegate to geometry change + // 2) The columns range changed: update the rows and create a new state with the same rows map from the old one, + // but the columnsChanged flag set to true + // 3) Neither of the two ranges changed, we still need to trigger the layout so that columns, rows and cells are + // positioned and sized correctly + if (!rowsRange.equals(state.getRowsRange())) { + onGeometryChanged(GeometryChangeType.OTHER); + return; + } + if (!columnsRange.equals(state.getColumnsRange())) { + for (VFXTableRow row : state.getRowsByIndex().values()) { + row.updateColumns(columnsRange, false); + } + VFXTableState newState = new VFXTableState<>(table, rowsRange, columnsRange, state.getRows()); + newState.setColumnsChanged(true); + table.update(newState); + return; + } + table.requestViewportLayout(); + } + + protected void onColumnsLayoutModeChanged() { + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + VFXTableState current = table.getState(); + + // Only when the mode switches from VARIABLE to FIXED, we must invalidate the hPos + ColumnsLayoutMode newMode = table.getColumnsLayoutMode(); + if (newMode == ColumnsLayoutMode.FIXED) { + invalidatingPos = true; + helper.invalidatePos(); + } + + // For both cases (VARIABLE -> FIXED, FIXED -> VARIABLE) we have to do the same exact update. + // What may change when switching modes is having a different columns range and maybe an invalid hPos (see above). + // The estimated height, the rows range and the vPos are valid, which means that we can simply create a new state + // which uses all the rows from the current state and just update them with the new columns range. + IntegerRange columnsRange = helper.columnsRange(); + VFXTableState newState = new VFXTableState<>(table, current.getRowsRange(), columnsRange, current.getRows()); + newState.getRowsByIndex().values().forEach(r -> r.updateColumns(columnsRange, false)); + newState.setColumnsChanged(current); + table.update(newState); + if (newMode == ColumnsLayoutMode.FIXED && !newState.isLayoutNeeded()) table.requestViewportLayout(); + invalidatingPos = false; + } + + //================================================================================ + // Common + //================================================================================ + + protected void moveReuseCreateAlgorithm(IntegerRange rowsRange, IntegerRange columnsRange, VFXTableState newState) { + if (Utils.INVALID_RANGE.equals(rowsRange)) return; + VFXTable table = getNode(); + VFXTableState current = table.getState(); + ExcludingRange eRange = ExcludingRange.of(rowsRange); + if (!current.isEmpty()) { + for (Integer idx : rowsRange) { + VFXTableRow row = current.removeRow(idx); + if (row == null) continue; + eRange.exclude(idx); + row.updateColumns(columnsRange, false); // This will always be called! To the row checking if the update is actually needed + newState.addRow(idx, row); + } + } + remainingAlgorithm(eRange, newState); + } + + protected VFXTableState intersectionAlgorithm() { + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + + // New range + IntegerRange rowsRange = helper.rowsRange(); + ExcludingRange eRange = ExcludingRange.of(rowsRange); + + // Current and new states, intersection between current and new range + VFXTableState current = table.getState(); + VFXTableState newState = new VFXTableState<>(table, rowsRange, current.getColumnsRange()); + IntegerRange intersection = Utils.intersection(current.getRowsRange(), rowsRange); + + // If range valid, move common rows from current to new state. Also, exclude common indexes + if (rangeCheck(intersection, false, false)) { + for (Integer common : intersection) { + newState.addRow(common, current.removeRow(common)); + eRange.exclude(common); + } + } + + // Process remaining with the "remaining' algorithm" + remainingAlgorithm(eRange, newState); + return newState; + } + + protected void remainingAlgorithm(ExcludingRange eRange, VFXTableState newState) { + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + VFXTableState current = table.getState(); + + // Indexes in the given set were not found in the current state. + // Which means item updates. Rows are retrieved either from the current state (if not empty), from the cache, + // or created from the factory + for (Integer idx : eRange) { + T item = helper.indexToItem(idx); + VFXTableRow row; + if (!current.isEmpty()) { + row = current.getRows().pollFirst().getValue(); + row.updateIndex(idx); + row.updateItem(item); + } else { + row = helper.itemToRow(item); + row.updateIndex(idx); + newState.setRowsChanged(true); + } + row.updateColumns(newState.getColumnsRange(), false); // This needs to be done for new rows as well! + newState.addRow(idx, item, row); + } + } + + protected boolean tableFactorySizeCheck() { + VFXTable table = getNode(); + if (table.getColumns().isEmpty() || + table.isEmpty() || + table.getRowFactory() == null || + table.getRowHeight() <= 0 || + table.getWidth() <= 0 || + table.getHeight() <= 0) { + disposeCurrent(); + table.update(computeInvalidState()); + invalidatingPos = false; + return false; + } + return true; + } + + @SuppressWarnings("unchecked") + protected boolean rangeCheck(IntegerRange range, boolean update, boolean dispose) { + VFXTable table = getNode(); + if (Utils.INVALID_RANGE.equals(range)) { + if (dispose) disposeCurrent(); + if (update) table.update(VFXTableState.EMPTY); + invalidatingPos = false; + return false; + } + return true; + } + + protected boolean disposeCurrent() { + VFXTableState state = getNode().getState(); + if (!state.isEmpty()) { + state.dispose(); + return true; + } + return false; + } + + @SuppressWarnings("unchecked") + protected VFXTableState computeInvalidState() { + VFXTable table = getNode(); + VFXTableHelper helper = table.getHelper(); + IntegerRange columnsRange = helper.columnsRange(); + if (Utils.INVALID_RANGE.equals(columnsRange)) return VFXTa \ No newline at end of file diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableRow.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableRow.java new file mode 100644 index 0000000..fba3a1b --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableRow.java @@ -0,0 +1,245 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.virtualizedfx.base.VFXStyleable; +import io.github.palexdev.virtualizedfx.cells.Cell; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.utils.IndexBiMap.RowsStateMap; +import io.github.palexdev.virtualizedfx.utils.Utils; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.ReadOnlyIntegerWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.scene.Node; +import javafx.scene.layout.Region; + +import java.util.*; +import java.util.function.Function; + +public abstract class VFXTableRow extends Region implements Cell, VFXStyleable { + //================================================================================ + // Properties + //================================================================================ + private final ReadOnlyObjectWrapper> table = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyIntegerWrapper index = new ReadOnlyIntegerWrapper(-1); + private final ReadOnlyObjectWrapper item = new ReadOnlyObjectWrapper<>(); + protected IntegerRange columnsRange = Utils.INVALID_RANGE; + protected RowsStateMap> cells; + + //================================================================================ + // Constructors + //================================================================================ + public VFXTableRow(T item) { + cells = new RowsStateMap<>(); + updateItem(item); + initialize(); + } + + //================================================================================ + // Abstract Methods + //=============================================================================== + protected abstract void updateColumns(IntegerRange columnsRange, boolean changed); + + protected abstract boolean replaceCells(VFXTableColumn> column); + + //================================================================================ + // Methods + //================================================================================ + private void initialize() { + getStyleClass().setAll(defaultStyleClasses()); + } + + @SuppressWarnings("unchecked") + protected void copyState(VFXTableRow other) { + updateIndex(other.getIndex()); + this.columnsRange = other.columnsRange; + this.cells = other.cells; + other.cells = RowsStateMap.EMPTY; + cells.getByIndex().values().forEach(c -> c.updateRow(this)); + onCellsChanged(); + // A layout request is also needed! + } + + protected void clear() { + saveAllCells(); + setIndex(-1); + setItem(null); + columnsRange = Utils.INVALID_RANGE; + getChildren().clear(); + } + + protected void onCellsChanged() { + getChildren().setAll(getCellsAsNodes()); + } + + protected TableCell getCell(int index, VFXTableColumn> column, boolean useCache) { + T item = getItem(); + TableCell cell; + if (useCache && column.cacheSize() > 0) { // Try cache first + cell = column.cache().take(); + cell.updateItem(item); + } else { // Create new otherwise + Function> cellFactory = column.getCellFactory(); + if (cellFactory == null) return null; // Take into account null generators + cell = cellFactory.apply(item); + } + cell.updateRow(this); + cell.updateColumn(column); + cell.updateIndex(index); + return cell; + } + + protected void saveCell(VFXTableColumn> column, TableCell cell) { + column.cache().cache(cell); + cell.updateRow(null); + cell.updateColumn(null); + // A cell (or row) that is not in the viewport anymore should state it clearly + // This is the convention, therefore, set both to 'null' + } + + @SuppressWarnings("unchecked") + protected boolean saveAllCells() { + if (cells.isEmpty()) return false; + cells.getByKey().forEach((c, idxs) -> { + for (Integer idx : idxs) { + saveCell((VFXTableColumn>) c, cells.get(idx)); + } + }); + cells.clear(); + return true; + } + + protected void layoutCells() { + // It's crucial to process the layout this way. + // Some columns may not be present in the map as the cell factory could be null or produce null cells. + // So, we have to skip such cases, but we still need to increment the 'i' counter to get the correct absolute position + VFXTable table = getTable(); + if (table == null || !table.isNeedsViewportLayout()) return; + VFXTableHelper helper = table.getHelper(); + int i = 0; + for (Integer idx : columnsRange) { + TableCell cell = cells.get(idx); + if (cell != null) helper.layoutCell(i, cell.toNode()); + i++; + } + } + + // TODO EXPERIMENTAL + protected void partialLayout(VFXTableColumn column, double oldW, double newW) { + VFXTable table = getTable(); + VFXTableHelper helper = table.getHelper(); + int cIndex = table.indexOf(column); + IntegerRange range = IntegerRange.of(cIndex, columnsRange.getMax()); + for (Integer idx : range) { + VFXTableColumn c = table.getColumns().get(idx); + if (!helper.isInViewport(c)) break; + + TableCell cell = cells.get(idx); + if (cell == null) continue; + Node node = cell.toNode(); + if (!node.isVisible()) { + helper.layoutCell(idx, node); + continue; + } + + if (idx == cIndex) { + node.resize(newW, getHeight()); + continue; + } + + double newX = helper.getColumnPos(idx, c); + node.relocate(newX, 0); + cell.toNode().setVisible(true); + } + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + public Region toNode() { + return this; + } + + @Override + public void updateIndex(int index) { + setIndex(index); + } + + @Override + public void updateItem(T item) { + setItem(item); + } + + @Override + public List defaultStyleClasses() { + return List.of("vfx-row"); + } + + @Override + protected void layoutChildren() {} + + @Override + public void dispose() { + clear(); + setTable(null); + } + + //================================================================================ + // Getters/Setters + //================================================================================ + + public VFXTable getTable() { + return table.get(); + } + + public ReadOnlyObjectProperty> tableProperty() { + return table.getReadOnlyProperty(); + } + + protected void setTable(VFXTable table) { + this.table.set(table); + } + + public int getIndex() { + return index.get(); + } + + public ReadOnlyIntegerProperty indexProperty() { + return index.getReadOnlyProperty(); + } + + protected void setIndex(int index) { + this.index.set(index); + } + + public T getItem() { + return item.get(); + } + + public ReadOnlyObjectProperty itemProperty() { + return item.getReadOnlyProperty(); + } + + protected void setItem(T item) { + this.item.set(item); + } + + public IntegerRange getColumnsRange() { + return columnsRange; + } + + public SequencedMap> getCellsUnmodifiable() { + return Collections.unmodifiableSequencedMap(cells.getByIndex()); + } + + protected SequencedMap> getCells() { + return cells.getByIndex(); + } + + public List getCellsAsNodes() { + return getCells().values().stream() + .map(Cell::toNode) + .toList(); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableSkin.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableSkin.java new file mode 100644 index 0000000..204a813 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableSkin.java @@ -0,0 +1,260 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.beans.Position; +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.mfxcore.controls.SkinBase; +import io.github.palexdev.virtualizedfx.enums.GeometryChangeType; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Orientation; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Rectangle; + +import static io.github.palexdev.mfxcore.observables.When.onChanged; +import static io.github.palexdev.mfxcore.observables.When.onInvalidated; + +public class VFXTableSkin extends SkinBase, VFXTableManager> { + //================================================================================ + // Properties + //================================================================================ + // TODO explain why so many nodes + private final Pane viewport; + private final Rectangle clip; + + private final Pane cContainer; + + private final Pane rContainer; + private final Rectangle rClip; + + private ListChangeListener> columnsListener; + + //================================================================================ + // Constructors + //================================================================================ + public VFXTableSkin(VFXTable table) { + super(table); + + // Init containers + cContainer = new Pane() { + @Override + protected void layoutChildren() { + layoutColumns(); + } + }; + cContainer.getStyleClass().add("columns"); + + rContainer = new Pane() { + @Override + protected void layoutChildren() { + layoutRows(); + } + }; + rContainer.getStyleClass().add("rows"); + + viewport = new Pane(cContainer, rContainer) { + @Override + protected void layoutChildren() { + VFXTableSkin.this.layout(); + } + }; + viewport.getStyleClass().add("viewport"); + + // Init clips + clip = new Rectangle(); + clip.widthProperty().bind(table.widthProperty()); + clip.heightProperty().bind(table.heightProperty()); + clip.arcWidthProperty().bind(table.clipBorderRadiusProperty()); + clip.arcHeightProperty().bind(table.clipBorderRadiusProperty()); + //table.setClip(clip); + + rClip = new Rectangle(); + rClip.widthProperty().bind(rContainer.widthProperty()); + rClip.heightProperty().bind(rContainer.heightProperty()); + rClip.translateYProperty().bind(rContainer.translateYProperty().multiply(-1)); + //rContainer.setClip(rClip); + + // End initialization + addListeners(); + getChildren().add(viewport); + } + + //================================================================================ + // Methods + //================================================================================ + + protected void addListeners() { + VFXTable table = getSkinnable(); + + // This needs to be a classical listener + columnsListener = getBehavior()::onColumnsChanged; + table.getColumns().addListener(columnsListener); + getBehavior().onColumnsChanged(null); // This is needed since the skin is created afterward + + listeners( + // Core changes + onInvalidated(table.stateProperty()) + .then(s -> { + if (s.isClone()) return; + if (s == VFXTableState.EMPTY) { + cContainer.getChildren().clear(); + rContainer.getChildren().clear(); + return; + } + if (s.isEmpty()) { + rContainer.getChildren().clear(); + } else if (s.haveRowsChanged()) { + rContainer.getChildren().setAll(s.getRowsByIndex().values()); + } + if (s.haveColumnsChanged()) { + cContainer.getChildren().setAll( + table.getColumns().subList( + s.getColumnsRange().getMin(), + s.getColumnsRange().getMax() + 1 + ) + ); + } + if (s.isLayoutNeeded()) table.requestViewportLayout(); + }), + onInvalidated(table.needsViewportLayoutProperty()) + .condition(v -> v) + .then(v -> { + layoutColumns(); + layoutRows(); + }), + onInvalidated(table.helperProperty()) + .then(h -> { + viewport.translateXProperty().bind(h.viewportPositionProperty().map(Position::getX)); + rContainer.translateYProperty().bind(h.viewportPositionProperty().map(Position::getY)); + }) + .executeNow(), + + // Geometry changes + /* + * BUG: unfortunately we must use a ChangeListener here because JavaFX is stupid. + * You see, for the VARIABLE_MODE layout we rely on a cache to compute the columns' width only when needed. + * The last column is a special case because it's the only one for which the value becomes invalid if the + * table's width changes. Since JavaFX bindings use some sort of InvalidationListeners on the dependencies to + * invalidate the bindings itself, there's a huge pain in the ass problem: priority. + * Under the hood, these things are simple; there is a for loop somewhere that calls the listeners + * (or at least you can think at the mechanism like this), which means that if a listener is added before + * another one, it's executed first. + * This is a huge problem here, because we can't proceed with the onGeometryChanged() computation before the + * cache is invalidated. + * A simple workaround is to use ChangeListeners which are always invoked AFTER InvalidationListeners. + * + * In my opinion, this mechanism is stupid and broken, an InvalidationListener whose purpose is to invalidate + * a binding should ALWAYS be called BEFORE any other InvalidationListener + */ + onChanged(table.widthProperty()) + .then((ow, nw) -> getBehavior().onGeometryChanged(GeometryChangeType.WIDTH, ow.doubleValue(), nw.doubleValue())), + onInvalidated(table.heightProperty()) + .then(h -> getBehavior().onGeometryChanged(GeometryChangeType.HEIGHT)), + onInvalidated(table.columnsBufferSizeProperty()) + .then(b -> getBehavior().onGeometryChanged(GeometryChangeType.OTHER)), + onInvalidated(table.rowsBufferSizeProperty()) + .then(b -> getBehavior().onGeometryChanged(GeometryChangeType.OTHER)), + + // Position changes + onInvalidated(table.vPosProperty()) + .then(v -> getBehavior().onPositionChanged(Orientation.VERTICAL)), + onInvalidated(table.hPosProperty()) + .then(h -> getBehavior().onPositionChanged(Orientation.HORIZONTAL)), + + // Others + onInvalidated(table.itemsProperty()) + .then(it -> getBehavior().onItemsChanged()), + onInvalidated(table.rowFactoryProperty()) + .then(rf -> getBehavior().onRowFactoryChanged()), + onInvalidated(table.rowHeightProperty()) + .then(h -> getBehavior().onRowHeightChanged()), + onInvalidated(table.columnsSizeProperty()) + .then(s -> getBehavior().onColumnsSizeChanged()), + onInvalidated(table.columnsLayoutModeProperty()) + .then(m -> getBehavior().onColumnsLayoutModeChanged()) + ); + } + + protected void layout() { + VFXTable table = getSkinnable(); + + // First layout the columns and rows containers + double w = table.getVirtualMaxX(); + double h = table.getHeight(); + double cH = table.getColumnsSize().getHeight(); + double rH = h - cH; + cContainer.resizeRelocate(0, 0, w, cH); + rContainer.resizeRelocate(0, cH, w, rH); + } + + protected void layoutColumns() { + VFXTable table = getSkinnable(); + + /* TODO this should not be used here in theory + * because in case of VARIABLE layout mode we can't tell when columns will change their width + * Which rises another issue, if we can't tell, how can we tell the rows to re-layout the cells? + * Maybe at the end of the day we'll have to use the event bus or a listener + */ + if (!table.isNeedsViewportLayout()) return; + + VFXTableState state = table.getState(); + if (state == VFXTableState.EMPTY) return; + + VFXTableHelper helper = table.getHelper(); + IntegerRange columnsRange = state.getColumnsRange(); + int i = 0; + ObservableList> columns = table.getColumns(); + for (Integer idx : columnsRange) { + VFXTableColumn column = columns.get(idx); + updateColumnIndex(column, idx); // Updating the columns' index here should ensure to always have a correct index + helper.layoutColumn(i, column); + i++; + } + } + + protected void layoutRows() { + VFXTable table = getSkinnable(); + if (!table.isNeedsViewportLayout()) return; + + VFXTableHelper helper = table.getHelper(); + VFXTableState state = table.getState(); + if (state != VFXTableState.EMPTY && !state.isEmpty()) { + int i = 0; + for (VFXTableRow row : state.getRowsByIndex().values()) { + helper.layoutRow(i, row); + row.layoutCells(); + i++; + } + onLayoutCompleted(true); + return; + } + onLayoutCompleted(false); + } + + protected void onLayoutCompleted(boolean done) { + VFXTable table = getSkinnable(); + table.setNeedsViewportLayout(false); + } + + // TODO documentation here is important (why, why here) + protected void updateColumnIndex(VFXTableColumn column, int index) { + column.setIndex(index); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + protected void initBehavior(VFXTableManager behavior) { + behavior.init(); + } + + @Override + public void dispose() { + VFXTable table = getSkinnable(); + if (columnsListener != null) { + table.getColumns().removeListener(columnsListener); + columnsListener = null; + } + super.dispose(); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableState.java b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableState.java new file mode 100644 index 0000000..2ee27ba --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/VFXTableState.java @@ -0,0 +1,224 @@ +package io.github.palexdev.virtualizedfx.table; + +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.virtualizedfx.utils.IndexBiMap.StateMap; +import io.github.palexdev.virtualizedfx.utils.Utils; + +import java.util.*; + +@SuppressWarnings({"rawtypes", "DataFlowIssue", "SameParameterValue"}) +public class VFXTableState implements Cloneable { + //================================================================================ + // Static Properties + //================================================================================ + /** + * Special instance of {@code VFXTableState} used to indicate that no rows can be present in the viewport at + * a certain time. The reasons can be many, for example, invalid range, width/height <= 0, etc... + *

+ * This and {@link #isEmpty()} are two total different things!! + */ + public static final VFXTableState EMPTY = new VFXTableState() { + @Override + protected VFXTableRow removeRow(int index) {return null;} + + @Override + protected VFXTableRow removeRow(Object item) {return null;} + + @Override + protected void dispose() {} + + // This must not generate any clones, this is a special state + @SuppressWarnings("MethodDoesntCallSuperMethod") + @Override + public VFXTableState clone() { + return this; + } + }; + + //================================================================================ + // Properties + //================================================================================ + private final VFXTable table; + private final IntegerRange rowsRange; + private final IntegerRange columnsRange; + private final StateMap> rows; + private boolean rowsChanged = false; + private boolean columnsChanged = false; + private boolean clone = false; + + //================================================================================ + // Constructors + //================================================================================ + private VFXTableState() { + this.table = null; + this.rowsRange = Utils.INVALID_RANGE; + this.columnsRange = Utils.INVALID_RANGE; + this.rows = new StateMap<>(); + } + + public VFXTableState(VFXTable table, IntegerRange rowsRange, IntegerRange columnsRange) { + this.table = table; + this.rowsRange = rowsRange; + this.columnsRange = columnsRange; + this.rows = new StateMap<>(); + } + + protected VFXTableState(VFXTable table, IntegerRange rowsRange, IntegerRange columnsRange, StateMap> rows) { + this.table = table; + this.rowsRange = rowsRange; + this.columnsRange = columnsRange; + this.rows = rows; + } + + //================================================================================ + // Methods + //================================================================================ + protected void addRow(int index, VFXTableRow row) { + addRow(index, table.getItems().get(index), row); + } + + protected void addRow(int index, T item, VFXTableRow row) { + rows.put(index, item, row); + } + + protected VFXTableRow removeRow(int index) { + VFXTableRow r = rows.remove(index); + if (r == null) r = removeRow(table.getItems().get(index)); + return r; + } + + protected VFXTableRow removeRow(T item) { + return rows.remove(item); + } + + protected void dispose() { + getRowsByIndex().values().forEach(r -> { + r.clear(); + table.getCache().cache(r); + }); + rows.clear(); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @SuppressWarnings("MethodDoesntCallSuperMethod") + @Override + public VFXTableState clone() { + VFXTableState clone = new VFXTableState<>( + table, + rowsRange, + columnsRange, + rows + ); + clone.clone = true; + return clone; + } + + + //================================================================================ + // Getters/Setters + //================================================================================ + + /** + * @return the {@link VFXTable} instance this state is associated to + */ + public VFXTable getTable() { + return table; + } + + /** + * @return the range of rows to display + */ + public IntegerRange getRowsRange() { + return rowsRange; + } + + /** + * @return the range of columns to display + */ + public IntegerRange getColumnsRange() { + return columnsRange; + } + + protected StateMap> getRows() { + return rows; + } + + protected SequencedMap> getRowsByIndex() { + return rows.getByIndex(); + } + + protected Map> getRowsByItem() { + return rows.getByKey(); + } + + public SequencedMap> getRowsByIndexUnmodifiable() { + return Collections.unmodifiableSequencedMap(rows.getByIndex()); + } + + public Map> getRowsByItemUnmodifiable() { + return Collections.unmodifiableMap(rows.getByKey()); + } + + /** + * @return the total number of cells by summing the number cells of each row in the {@link StateMap} + */ + public int cellsNum() { + return getRowsByIndex().values().stream() + .mapToInt(r -> r.getCells().size()) + .sum(); + } + + /** + * @return the number of rows in the {@link StateMap} + */ + public int size() { + return rows.size(); + } + + /** + * @return whether the {@link StateMap} is empty + * @see StateMap + */ + public boolean isEmpty() { + return rows.isEmpty(); + } + + /** + * @return whether the rows have changed since the last state. This is used to indicate if more or less rows are + * present in this state compared to the old one. Used by the default skin to check whether the viewport has to + * update its children or not. + * @see VFXTableSkin + */ + public boolean haveRowsChanged() { + return rowsChanged; + } + + /** + * @see #haveRowsChanged() + */ + protected void setRowsChanged(boolean rowsChanged) { + this.rowsChanged = rowsChanged; + } + + public boolean haveColumnsChanged() { + return columnsChanged; + } + + protected void setColumnsChanged(boolean columnsChanged) { + this.columnsChanged = columnsChanged; + } + + protected void setColumnsChanged(VFXTableState other) { + setColumnsChanged(!Objects.equals(columnsRange, other.columnsRange)); + } + + public boolean isClone() { + return clone; + } + + public boolean isLayoutNeeded() { + return columnsChanged || rowsChanged; + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumn.java b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumn.java new file mode 100644 index 0000000..a16f43b --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumn.java @@ -0,0 +1,263 @@ +package io.github.palexdev.virtualizedfx.table.defaults; + +import io.github.palexdev.mfxcore.base.properties.styleable.StyleableBooleanProperty; +import io.github.palexdev.mfxcore.behavior.BehaviorBase; +import io.github.palexdev.mfxcore.controls.SkinBase; +import io.github.palexdev.mfxcore.enums.Zone; +import io.github.palexdev.mfxcore.utils.fx.StyleUtils; +import io.github.palexdev.mfxcore.utils.resize.RegionDragResizer; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode; +import io.github.palexdev.virtualizedfx.table.VFXTable; +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import javafx.css.*; +import javafx.geometry.HPos; +import javafx.scene.Node; +import javafx.scene.input.MouseEvent; + +import java.util.List; +import java.util.function.Supplier; + +public class VFXDefaultTableColumn> extends VFXTableColumn { + //================================================================================ + // Properties + //================================================================================ + private RegionDragResizer resizer; + public static final PseudoClass DRAGGED = PseudoClass.getPseudoClass("dragged"); + + //================================================================================ + // Constructors + //================================================================================ + public VFXDefaultTableColumn() { + initialize(); + } + + public VFXDefaultTableColumn(String text) { + super(text); + initialize(); + } + + public VFXDefaultTableColumn(String text, Node graphic) { + super(text, graphic); + initialize(); + } + + //================================================================================ + // Methods + //================================================================================ + private void initialize() { + setupDragResizer(); + } + + protected void setupDragResizer() { + resizer = new RegionDragResizer(this) { + @Override + protected void handleDragged(MouseEvent event) { + if (getTable() == null || getTable().getColumnsLayoutMode() == ColumnsLayoutMode.FIXED) return; + super.handleDragged(event); + pseudoClassStateChanged(DRAGGED, true); + } + + @Override + protected void handleMoved(MouseEvent event) { + if (getTable() == null || getTable().getColumnsLayoutMode() == ColumnsLayoutMode.FIXED) return; + super.handleMoved(event); + } + + @Override + protected void handlePressed(MouseEvent event) { + if (getTable() == null || getTable().getColumnsLayoutMode() == ColumnsLayoutMode.FIXED) return; + super.handlePressed(event); + } + + @Override + protected void handleReleased(MouseEvent event) { + super.handleReleased(event); + pseudoClassStateChanged(DRAGGED, false); + } + }; + resizer.setMinWidthFunction(r -> getTable().getColumnsSize().getWidth()); + resizer.setAllowedZones(Zone.CENTER_RIGHT); + resizer.setResizeHandler((node, x, y, w, h) -> resize(w)); + resizer.makeResizable(); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + protected SkinBase buildSkin() { + return new VFXDefaultTableColumnSkin<>(this); + } + + @Override + public Supplier>> defaultBehaviorProvider() { + // TODO define default behavior? + return () -> new BehaviorBase<>(this) {}; + } + + //================================================================================ + // Styleable Properties + //================================================================================ + private final StyleableObjectProperty iconAlignment = new SimpleStyleableObjectProperty<>( + StyleableProperties.ICON_ALIGNMENT, + this, + "iconAlignment", + HPos.RIGHT + ); + + private final StyleableBooleanProperty enableOverlay = new StyleableBooleanProperty( + StyleableProperties.ENABLE_OVERLAY, + this, + "enableOverlay", + true + ); + + private final StyleableBooleanProperty overlayOnHeader = new StyleableBooleanProperty( + StyleableProperties.OVERLAY_ON_HEADER, + this, + "overlayOnHeader", + false + ); + + private final StyleableBooleanProperty resizable = new StyleableBooleanProperty( + StyleableProperties.RESIZABLE, + this, + "resizable", + true + ) { + @Override + protected void invalidated() { + resizer.uninstall(); + boolean val = get(); + if (val) resizer.makeResizable(); + } + }; + + + public HPos getIconAlignment() { + return iconAlignment.get(); + } + + /** + * Specifies the side on which the icon will be placed. + *

+ * {@link HPos#CENTER} is ignored by the default skin, the icon will be placed to the right. + *

+ * This is settable via CSS with the "-vfx-icon-alignment" property. + */ + public StyleableObjectProperty iconAlignmentProperty() { + return iconAlignment; + } + + public void setIconAlignment(HPos iconAlignment) { + this.iconAlignment.set(iconAlignment); + } + + public boolean isEnableOverlay() { + return enableOverlay.get(); + } + + /** + * Specifies whether the default skin should enable the overlay. + *

+ * {@link VFXTable} is organized by rows. This means that by default, there is no way in the UI to display + * when a column is selected or hovered by the mouse. The default skin allows to do this by adding an extra node that + * extends from the column all the way down to the table's bottom. This allows doing cool tricks with CSS. + *

+ * One thing to keep in mind, though, is that if you define a background color for the overlay, make sure that it is + * opaque otherwise it will end up covering the cells. + *

+ * This is also settable via CSS with the "-vfx-enable-overlay" property. + */ + public StyleableBooleanProperty enableOverlayProperty() { + return enableOverlay; + } + + public void setEnableOverlay(boolean enableOverlay) { + this.enableOverlay.set(enableOverlay); + } + + public boolean isOverlayOnHeader() { + return overlayOnHeader.get(); + } + + /** + * Specifies whether the overlay should also cover the header of the column, + * the part where the text and the icon reside. + *

+ * This is also settable via CSS with the "-vfx-overlay-on-header" property. + */ + public StyleableBooleanProperty overlayOnHeaderProperty() { + return overlayOnHeader; + } + + public void setOverlayOnHeader(boolean overlayOnHeader) { + this.overlayOnHeader.set(overlayOnHeader); + } + + public boolean isResizable() { + return resizable.get(); + } + + public StyleableBooleanProperty resizableProperty() { + return resizable; + } + + public void setResizable(boolean resizable) { + this.resizable.set(resizable); + } + + //================================================================================ + // CssMetaData + //================================================================================ + private static class StyleableProperties { + private static final StyleablePropertyFactory> FACTORY = new StyleablePropertyFactory<>(VFXTableColumn.getClassCssMetaData()); + private static final List> cssMetaDataList; + + private static final CssMetaData, HPos> ICON_ALIGNMENT = + FACTORY.createEnumCssMetaData( + HPos.class, + "-vfx-icon-alignment", + VFXDefaultTableColumn::iconAlignmentProperty, + HPos.RIGHT + ); + + private static final CssMetaData, Boolean> ENABLE_OVERLAY = + FACTORY.createBooleanCssMetaData( + "-vfx-enable-overaly", + VFXDefaultTableColumn::enableOverlayProperty, + true + ); + + private static final CssMetaData, Boolean> OVERLAY_ON_HEADER = + FACTORY.createBooleanCssMetaData( + "-vfx-overlay-on-header", + VFXDefaultTableColumn::overlayOnHeaderProperty, + false + ); + + private static final CssMetaData, Boolean> RESIZABLE = + FACTORY.createBooleanCssMetaData( + "-vfx-resizable", + VFXDefaultTableColumn::resizableProperty, + true + ); + + static { + cssMetaDataList = StyleUtils.cssMetaDataList( + VFXTableColumn.getClassCssMetaData(), + ICON_ALIGNMENT, ENABLE_OVERLAY, OVERLAY_ON_HEADER, RESIZABLE + ); + } + } + + @Override + public List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + public static List> getClassCssMetaData() { + return StyleableProperties.cssMetaDataList; + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumnSkin.java b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumnSkin.java new file mode 100644 index 0000000..edec2ef --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableColumnSkin.java @@ -0,0 +1,134 @@ +package io.github.palexdev.virtualizedfx.table.defaults; + +import io.github.palexdev.mfxcore.behavior.BehaviorBase; +import io.github.palexdev.mfxcore.controls.BoundLabel; +import io.github.palexdev.mfxcore.controls.SkinBase; +import io.github.palexdev.mfxcore.utils.fx.LayoutUtils; +import io.github.palexdev.mfxcore.utils.fx.TextMeasurementCache; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode; +import io.github.palexdev.virtualizedfx.table.VFXTable; +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import javafx.geometry.HPos; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; + +import static io.github.palexdev.mfxcore.observables.When.onChanged; +import static io.github.palexdev.mfxcore.observables.When.onInvalidated; + +public class VFXDefaultTableColumnSkin> extends SkinBase, BehaviorBase>> { + //================================================================================ + // Properties + //================================================================================ + private final HBox box; + private final BoundLabel label; + private final Region overlay; + private final TextMeasurementCache tmc; + + //================================================================================ + // Constructors + //================================================================================ + public VFXDefaultTableColumnSkin(VFXDefaultTableColumn column) { + super(column); + + // Init text node + label = new BoundLabel(column); + label.graphicProperty().unbind(); + label.setGraphic(null); + label.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + HBox.setHgrow(label, Priority.ALWAYS); + tmc = new TextMeasurementCache(column); + + // Init overlay + overlay = new Region(); + overlay.getStyleClass().add("overlay"); + overlay.setManaged(false); + + // Init container + // Let an HBox manage the layout, much easier in this case + box = new HBox(label); + box.setAlignment(Pos.CENTER); + box.spacingProperty().bind(label.graphicTextGapProperty()); + box.getStyleClass().add("layout"); + + + // Finalize initialization + addListeners(); + getChildren().addAll(box, overlay); + } + + //================================================================================ + // Methods + //================================================================================ + private void addListeners() { + VFXTableColumn column = getSkinnable(); + listeners( + onChanged(column.graphicProperty()) + .then(this::handleIcon) + .executeNow(), + onInvalidated(column.tableProperty()) + .then(t -> column.requestLayout()) + ); + } + + protected void handleIcon(Node oldIcon, Node newIcon) { + VFXDefaultTableColumn column = getColumn(); + if (oldIcon != null) box.getChildren().remove(oldIcon); + if (newIcon != null) { + HPos pos = column.getIconAlignment(); + int index = (pos == HPos.LEFT) ? 0 : box.getChildren().size(); + box.getChildren().add(index, newIcon); + } + } + + protected VFXDefaultTableColumn getColumn() { + return (VFXDefaultTableColumn) getSkinnable(); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + protected void initBehavior(BehaviorBase> behavior) { + behavior.init(); + } + + @Override + protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + VFXTableColumn column = getSkinnable(); + VFXTable table = column.getTable(); + double minW; + if (table != null && table.getColumnsLayoutMode() == ColumnsLayoutMode.VARIABLE && (minW = table.getColumnsSize().getWidth()) > 0) { + return Math.max(LayoutUtils.boundWidth(box), minW); + } + return super.computeMinWidth(height, topInset, rightInset, bottomInset, leftInset); + } + + @Override + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + VFXTableColumn column = getSkinnable(); + VFXTable table = column.getTable(); + if (table != null && table.getColumnsLayoutMode() == ColumnsLayoutMode.VARIABLE) { + Node icon = column.getGraphic(); + double gap = column.getGraphicTextGap(); + return leftInset + tmc.getSnappedWidth() + (icon != null ? LayoutUtils.boundWidth(icon) + gap : 0.0) + rightInset; + } + return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset); + } + + @Override + protected void layoutChildren(double x, double y, double w, double h) { + super.layoutChildren(x, y, w, h); + + // Overlay layout + VFXDefaultTableColumn column = getColumn(); + VFXTable table = column.getTable(); + double oW = snappedRightInset() + w + snappedLeftInset(); + double oH = (table != null) ? table.getHeight() : 0.0; + double oY = (column.isOverlayOnHeader()) ? 0.0 : h; + overlay.resizeRelocate(0.0, oY, oW, oH); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableRow.java b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableRow.java new file mode 100644 index 0000000..45db0f2 --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXDefaultTableRow.java @@ -0,0 +1,92 @@ +package io.github.palexdev.virtualizedfx.table.defaults; + +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.table.VFXTable; +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import io.github.palexdev.virtualizedfx.table.VFXTableHelper; +import io.github.palexdev.virtualizedfx.table.VFXTableRow; +import io.github.palexdev.virtualizedfx.utils.IndexBiMap.RowsStateMap; +import javafx.scene.Node; + +import java.util.SequencedSet; + +public class VFXDefaultTableRow extends VFXTableRow { + + + //================================================================================ + // Constructors + //================================================================================ + public VFXDefaultTableRow(T item) { + super(item); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @SuppressWarnings("unchecked") + @Override + protected void updateColumns(IntegerRange columnsRange, boolean columnsChanged) { + if (!columnsChanged && super.columnsRange.equals(columnsRange)) return; + VFXTable table = getTable(); + RowsStateMap> nCells = new RowsStateMap<>(); + boolean update = false; + for (Integer index : columnsRange) { + VFXTableColumn> column = (VFXTableColumn>) table.getColumns().get(index); + TableCell cell = cells.remove(column); + + // Commons + if (cell != null) { + // Index needs to be updated only and only if the columns' list changed + if (columnsChanged) cell.updateIndex(index); + nCells.put(index, column, cell); + continue; + } + + // New columns + update = true; + TableCell nCell = getCell(index, column, true); + if (nCell != null) nCells.put(index, column, nCell); + } + + // Dispose before replacing state + update = saveAllCells() || update; + super.cells = nCells; + super.columnsRange = columnsRange; + if (update) onCellsChanged(); + } + + @Override + protected boolean replaceCells(VFXTableColumn> column) { + SequencedSet idxs = cells.getByKey().remove(column); + if (idxs == null) return false; + VFXTable table = getTable(); + VFXTableHelper helper = table.getHelper(); + boolean done = false; + for (Integer idx : idxs) { + // Cache old cell (will be disposed later by the column) + TableCell oCell = cells.remove(idx); + getChildren().remove(oCell.toNode()); + saveCell(column, oCell); + + // Create new cell (ignoring cache otherwise old will be used) + TableCell nCell = getCell(idx, column, false); + if (nCell == null) continue; // Skip if null + + Node nNode = nCell.toNode(); + cells.put(idx, column, nCell); + // Add the new cell to the children list + // Also size and position it, there should be no need to trigger a full layout + getChildren().add(nNode); + helper.layoutCell(idx - columnsRange.getMin(), nNode); + done = true; + } + return done; + } + + @Override + public void updateItem(T item) { + super.updateItem(item); + getCells().values().forEach(c -> c.updateItem(item)); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXSimpleTableCell.java b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXSimpleTableCell.java new file mode 100644 index 0000000..527f72a --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXSimpleTableCell.java @@ -0,0 +1,200 @@ +package io.github.palexdev.virtualizedfx.table.defaults; + +import io.github.palexdev.mfxcore.controls.Label; +import io.github.palexdev.mfxcore.utils.converters.FunctionalStringConverter; +import io.github.palexdev.virtualizedfx.base.VFXStyleable; +import io.github.palexdev.virtualizedfx.cells.MappingTableCell; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.table.VFXTable; +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import io.github.palexdev.virtualizedfx.table.VFXTableRow; +import javafx.beans.property.*; +import javafx.scene.Node; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.util.StringConverter; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +// TODO move to cells package? (also make base sub-package) +public class VFXSimpleTableCell extends HBox implements MappingTableCell, VFXStyleable { + //================================================================================ + // Properties + //================================================================================ + private final ReadOnlyObjectWrapper>> column = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyObjectWrapper> row = new ReadOnlyObjectWrapper<>(); + private final IntegerProperty index = new SimpleIntegerProperty(-1); + private final ObjectProperty item = new SimpleObjectProperty<>(); + private Function extractor; + private StringConverter converter; + + protected final Label label; + //protected When inViewportWhen; TODO figure out + + //================================================================================ + // Constructors + //================================================================================ + public VFXSimpleTableCell(T item, Function extractor) { + this(item, extractor, FunctionalStringConverter.to(Objects::toString)); + } + + public VFXSimpleTableCell(T item, Function extractor, StringConverter converter) { + this.extractor = extractor; + this.converter = converter; + + label = new Label(); + label.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + setHgrow(label, Priority.ALWAYS); + getChildren().add(label); + + updateItem(item); + initialize(); + } + + //================================================================================ + // Methods + //================================================================================ + private void initialize() { + getStyleClass().setAll(defaultStyleClasses()); + } + + //================================================================================ + // Delegate Methods + //================================================================================ + public VFXTable getTable() { + return Optional.ofNullable(getRow()) + .map(VFXTableRow::getTable) + .orElse(null); + } + + public int getRowIndex() { + return Optional.ofNullable(getRow()) + .map(VFXTableRow::getIndex) + .orElse(-1); + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + public List defaultStyleClasses() { + return List.of("table-cell"); + } + + @Override + public Node toNode() { + return this; + } + + @Override + public void invalidate() { + T item = getItem(); + if (item == null) { + label.setText(""); + return; + } + E e = extractor.apply(item); + String s = converter.toString(e); + label.setText(s); + } + + @Override + public void updateIndex(int index) { + setIndex(index); + } + + @Override + public void updateItem(T item) { + setItem(item); + invalidate(); + } + + @Override + public void updateColumn(VFXTableColumn> column) { + setColumn(column); + } + + @Override + public void updateRow(VFXTableRow row) { + setRow(row); + } + + @Override + public Function getExtractor() { + return extractor; + } + + @Override + public void setExtractor(Function extractor) { + this.extractor = extractor; + } + + @Override + public StringConverter getConverter() { + return converter; + } + + @Override + public void setConverter(StringConverter converter) { + this.converter = converter; + } + + @Override + public void dispose() { + // TODO dispose when + } + + //================================================================================ + // Getters/Setters + //================================================================================ + public VFXTableColumn> getColumn() { + return column.get(); + } + + public ReadOnlyObjectProperty>> columnProperty() { + return column.getReadOnlyProperty(); + } + + protected void setColumn(VFXTableColumn> column) { + this.column.set(column); + } + + public VFXTableRow getRow() { + return row.get(); + } + + public ReadOnlyObjectProperty> rowProperty() { + return row.getReadOnlyProperty(); + } + + protected void setRow(VFXTableRow row) { + this.row.set(row); + } + + public int getIndex() { + return index.get(); + } + + public IntegerProperty indexProperty() { + return index; + } + + public void setIndex(int index) { + this.index.set(index); + } + + public T getItem() { + return item.get(); + } + + public ObjectProperty itemProperty() { + return item; + } + + public void setItem(T item) { + this.item.set(item); + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXUpdatingTableCell.java b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXUpdatingTableCell.java new file mode 100644 index 0000000..39f250e --- /dev/null +++ b/src/main/java/io/github/palexdev/virtualizedfx/table/defaults/VFXUpdatingTableCell.java @@ -0,0 +1,74 @@ +package io.github.palexdev.virtualizedfx.table.defaults; + +import io.github.palexdev.mfxcore.observables.When; +import io.github.palexdev.mfxcore.utils.converters.FunctionalStringConverter; +import javafx.beans.value.ObservableValue; +import javafx.util.StringConverter; + +import java.util.Objects; +import java.util.function.Function; + +// TODO move to cells package? (also make base sub-package) +public class VFXUpdatingTableCell extends VFXSimpleTableCell> { + //================================================================================ + // Properties + //================================================================================ + private ObservableValue property; + private When updateWhen; + + //================================================================================ + // Constructors + //================================================================================ + public VFXUpdatingTableCell(T item, Function> extractor) { + this(item, extractor, FunctionalStringConverter.to(p -> + (p != null) ? Objects.toString(p.getValue()) : "null") + ); + } + + public VFXUpdatingTableCell(T item, Function> extractor, StringConverter> converter) { + super(item, extractor, converter); + } + + //================================================================================ + // Methods + //================================================================================ + protected ObservableValue getProperty() { + if (property == null) { + property = getExtractor().apply(getItem()); + updateWhen = When.onInvalidated(property) + .then(o -> invalidate()) + .executeNow() + .listen(); + } + return property; + } + + //================================================================================ + // Overridden Methods + //================================================================================ + @Override + public void updateItem(T item) { +/* if (ivwWhen != null) ivwWhen.dispose(); + ivwWhen = null; + property = null; + TODO figure out + */ + + setItem(item); + invalidate(); + } + + @Override + public void invalidate() { + String toString = getConverter().toString(getProperty()); + label.setText(toString); + } + + @Override + public void dispose() { + super.dispose(); + property = null; + if (updateWhen != null) updateWhen.dispose(); + updateWhen = null; + } +} diff --git a/src/main/java/io/github/palexdev/virtualizedfx/utils/IndexBiMap.java b/src/main/java/io/github/palexdev/virtualizedfx/utils/IndexBiMap.java index 18dea48..6df9e50 100644 --- a/src/main/java/io/github/palexdev/virtualizedfx/utils/IndexBiMap.java +++ b/src/main/java/io/github/palexdev/virtualizedfx/utils/IndexBiMap.java @@ -312,6 +312,15 @@ public V remove(K key) { return byIndex.remove(index); } + public List removeAll(K key) { + SequencedSet set = byKey.remove(key); + if (set == null || set.isEmpty()) return List.of(); + return set.stream() + .map(this::remove) + .filter(Objects::nonNull) + .toList(); + } + /** * @return the size of this data structure. Since it is expected for both the maps to have the same size, * this delegates to the {@link Map#size()} method of the {@code byIndex} map. @@ -452,5 +461,11 @@ public static class StateMap> extends StateMapBase *

* By having mappings, we can quickly separate the cells that need to be updates from the ones that are ready to go. */ - public static class RowsStateMap> extends StateMapBase, T, C> {} + @SuppressWarnings("rawtypes") + public static class RowsStateMap> extends StateMapBase, T, C> { + public static final RowsStateMap EMPTY = new RowsStateMap<>() { + @Override + public void put(Integer index, VFXTableColumn key, Cell val) {} + }; + } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 053e98d..04535d4 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -25,6 +25,9 @@ // Properties exports io.github.palexdev.virtualizedfx.properties; + // Table + exports io.github.palexdev.virtualizedfx.table; + // Utils exports io.github.palexdev.virtualizedfx.utils; } \ No newline at end of file diff --git a/src/test/java/app/Playground.java b/src/test/java/app/Playground.java index 708ff3a..2b59651 100644 --- a/src/test/java/app/Playground.java +++ b/src/test/java/app/Playground.java @@ -1,32 +1,31 @@ package app; -import interactive.TestFXUtils.SimpleCell; -import interactive.grid.GridTestUtils.Grid; +import interactive.table.TableTestUtils.Table; +import interactive.table.TableTestUtils.User; import io.github.palexdev.mfxcore.base.TriConsumer; import io.github.palexdev.mfxcore.builders.InsetsBuilder; import io.github.palexdev.mfxcore.builders.bindings.StringBindingBuilder; import io.github.palexdev.mfxcore.controls.Label; import io.github.palexdev.mfxcore.events.WhenEvent; -import io.github.palexdev.mfxcore.utils.EnumUtils; -import io.github.palexdev.mfxcore.utils.PositionUtils; -import io.github.palexdev.mfxcore.utils.fx.ScrollUtils; import io.github.palexdev.mfxeffects.animations.MomentumTransition; -import io.github.palexdev.virtualizedfx.grid.VFXGridHelper; +import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode; +import io.github.palexdev.virtualizedfx.table.VFXTableHelper; import javafx.animation.Interpolator; import javafx.application.Application; import javafx.application.Platform; import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.Button; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.stage.Stage; -import utils.NodeMover; -import static utils.Utils.items; +import static utils.Utils.debugView; public class Playground extends Application { + private final int cnt = 20; @Override public void start(Stage primaryStage) { @@ -34,54 +33,54 @@ public void start(Stage primaryStage) { pane.setAlignment(Pos.TOP_LEFT); pane.setPadding(InsetsBuilder.all(20)); - Grid grid = new Grid(items(200)); - //grid.setBufferSize(BufferSize.standard()); - //grid.setColumnsNum(10); - grid.setSpacing(10); - grid.setAlignment(Pos.CENTER); - //grid.setMaxSize(400, 400); - pane.getChildren().add(grid); + Table table = new Table(User.users(cnt)); + table.setColumnsLayoutMode(ColumnsLayoutMode.VARIABLE); + pane.getChildren().add(table); + + table.setMaxSize(600.0, 400.0); + + WhenEvent.intercept(table, MouseEvent.MOUSE_CLICKED) + .condition(e -> e.getButton() == MouseButton.SECONDARY) + .process(e -> table.switchColumnsLayoutMode()) + .asFilter() + .register(); TriConsumer scrollFn = (b, d, m) -> { if (b) { - grid.setHPos(grid.getHPos() + d * m); + table.setHPos(table.getHPos() + d * m); } else { - grid.setVPos(grid.getVPos() + d * m); + table.setVPos(table.getVPos() + d * m); } }; - WhenEvent.intercept(grid, ScrollEvent.SCROLL) + WhenEvent.intercept(table, ScrollEvent.SCROLL) .process(e -> { - ScrollUtils.ScrollDirection sd = ScrollUtils.determineScrollDirection(e); - int mul = switch (sd) { - case UP, RIGHT -> -1; - case DOWN, LEFT -> 1; - }; + double delta = e.isShiftDown() ? e.getDeltaX() : e.getDeltaY(); + if (delta == 0) return; + int mul = (delta > 0) ? -1 : 1; + MomentumTransition.fromTime(50, 500) .setOnUpdate(d -> scrollFn.accept(e.isShiftDown(), d, mul)) .setInterpolatorFluent(Interpolator.EASE_OUT) .play(); + //scrollFn.accept(e.isShiftDown(), 50.0, mul); }) .asFilter() .register(); - NodeMover.install(pane); + //NodeMover.install(pane); -/* When.onInvalidated(grid.widthProperty()) - .then(w -> grid.autoArrange()) - .listen();*/ - - Scene scene = new Scene(pane, 400, 400); + Scene scene = new Scene(pane, 600, 400); primaryStage.setScene(scene); primaryStage.setOnHidden(e -> Platform.exit()); primaryStage.show(); primaryStage.centerOnScreen(); - showDebugInfo(grid); - //debugView(null, pane); + showDebugInfo(table); + debugView(null, pane); } - void showDebugInfo(Grid grid) { - VFXGridHelper helper = grid.getHelper(); + void showDebugInfo(Table table) { + VFXTableHelper helper = table.getHelper(); VBox pane = new VBox(30); pane.setPrefWidth(400); pane.setPadding(InsetsBuilder.all(10)); @@ -97,15 +96,15 @@ void showDebugInfo(Grid grid) { Label scrollable = new Label(); scrollable.textProperty().bind(StringBindingBuilder.build() .setMapper(() -> "Scrollable X/Y: %f / %f".formatted(helper.maxHScroll(), helper.maxVScroll())) - .addSources(helper.virtualMaxXProperty(), grid.widthProperty()) - .addSources(helper.virtualMaxYProperty(), grid.helperProperty()) + .addSources(helper.virtualMaxXProperty(), table.widthProperty()) + .addSources(helper.virtualMaxYProperty(), table.helperProperty()) .get() ); Label positions = new Label(); positions.textProperty().bind(StringBindingBuilder.build() - .setMapper(() -> "VPos/HPos: %f / %f".formatted(grid.getVPos(), grid.getHPos())) - .addSources(grid.vPosProperty(), grid.hPosProperty()) + .setMapper(() -> "VPos/HPos: %f / %f".formatted(table.getVPos(), table.getHPos())) + .addSources(table.vPosProperty(), table.hPosProperty()) .get() ); @@ -116,22 +115,7 @@ void showDebugInfo(Grid grid) { .get() ); - Label alignment = new Label(); - alignment.textProperty().bind(StringBindingBuilder.build() - .setMapper(() -> "Alignment: %s".formatted(grid.getAlignment())) - .addSources(grid.alignmentProperty()) - .get() - ); - - Button action = new Button("Change alignment"); - action.setOnAction(e -> { - Pos current = grid.getAlignment(); - Pos next = EnumUtils.next(Pos.class, current); - while (PositionUtils.isBaseline(next)) next = EnumUtils.next(Pos.class, next); - grid.setAlignment(next); - }); - - pane.getChildren().addAll(estimate, scrollable, positions, ranges, alignment, action); + pane.getChildren().addAll(estimate, scrollable, positions, ranges); Stage stage = new Stage(); Scene scene = new Scene(pane); diff --git a/src/test/java/interactive/TestFXUtils.java b/src/test/java/interactive/TestFXUtils.java index ee99a64..8c3387a 100644 --- a/src/test/java/interactive/TestFXUtils.java +++ b/src/test/java/interactive/TestFXUtils.java @@ -11,6 +11,7 @@ import java.util.concurrent.TimeoutException; +import static interactive.table.TableTestUtils.rowsCounter; import static io.github.palexdev.mfxcore.observables.When.onInvalidated; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -39,6 +40,11 @@ public static void assertCounter(int created, int layouts, int ixUpdates, int it counter.reset(); } + public static void resetCounters() { + counter.reset(); + rowsCounter.reset(); + } + public static StackPane setupStage() { StackPane pane = new StackPane(); try { diff --git a/src/test/java/interactive/grid/GridTests.java b/src/test/java/interactive/grid/GridTests.java index a85f90b..f7817e2 100644 --- a/src/test/java/interactive/grid/GridTests.java +++ b/src/test/java/interactive/grid/GridTests.java @@ -649,6 +649,21 @@ void testChangeCellSizeBottomRight(FxRobot robot) { assertLength(grid, (200.0 / 10) * 145, 10 * 145); } + @Test + void testChangeCellSizeTo0(FxRobot robot) { + StackPane pane = setupStage(); + Grid grid = new Grid(items(100)); + robot.interact(() -> pane.getChildren().add(grid)); + + // Init test + assertState(grid, IntegerRange.of(0, 5), IntegerRange.of(0, 4)); + assertCounter(30, 1, 30, 30, 0, 0, 0); + + robot.interact(() -> grid.setCellSize(0, 0)); + assertState(grid, INVALID_RANGE, INVALID_RANGE); + assertCounter(0, 0, 0, 0, 0, 30, 20); + } + @Test void testChangeColumnsNumTopLeft(FxRobot robot) { StackPane pane = setupStage(); diff --git a/src/test/java/interactive/list/ListTests.java b/src/test/java/interactive/list/ListTests.java index f683f3c..71e5fd4 100644 --- a/src/test/java/interactive/list/ListTests.java +++ b/src/test/java/interactive/list/ListTests.java @@ -439,6 +439,20 @@ void testChangeCellSizeBottom(FxRobot robot) { assertEquals(44 * 50, helper.getVirtualMaxY()); } + @Test + void testChangeCellSizeTo0(FxRobot robot) { + StackPane pane = setupStage(); + List list = new List(items(50)); + robot.interact(() -> pane.getChildren().add(list)); + + assertState(list, IntegerRange.of(0, 16)); + assertCounter(17, 1, 17, 17, 0, 0, 0); + + robot.interact(() -> list.setCellSize(0.0)); + assertState(list, INVALID_RANGE); + assertCounter(0, 0, 0, 0, 0, 17, 7); + } + @Test void testChangeList(FxRobot robot) { StackPane pane = setupStage(); @@ -465,7 +479,7 @@ void testChangeList(FxRobot robot) { // Scroll(to bottom) and change items property (fewer elements) robot.interact(() -> { - list.setVPos(Double.MAX_VALUE); + list.scrollToLast(); list.setItems(items(50, 50)); }); assertState(list, IntegerRange.of(33, 49)); diff --git a/src/test/java/interactive/list/PaginatedListTests.java b/src/test/java/interactive/list/PaginatedListTests.java index d2fc533..40d45ad 100644 --- a/src/test/java/interactive/list/PaginatedListTests.java +++ b/src/test/java/interactive/list/PaginatedListTests.java @@ -508,6 +508,21 @@ void testChangeCellSizeBottom(FxRobot robot) { assertEquals(1760, list.getVPos()); } + @Test + void testChangeCellSizeTo0(FxRobot robot) { + StackPane pane = TestFXUtils.setupStage(); + PList list = new PList(items(50)); + robot.interact(() -> pane.getChildren().add(list)); + + assertState(list, IntegerRange.of(0, 13)); + assertCounter(14, 1, 14, 14, 0, 0, 0); + + robot.interact(() -> list.setCellSize(0.0)); + assertState(list, INVALID_RANGE); + assertCounter(0, 1, 0, 0, 0, 14, 4); + // The triggered layout is a no-op task anyway, so no worries + } + @Test void testChangeList(FxRobot robot) { StackPane pane = TestFXUtils.setupStage(); diff --git a/src/test/java/interactive/table/ColumnsSizeCacheTests.java b/src/test/java/interactive/table/ColumnsSizeCacheTests.java new file mode 100644 index 0000000..f7bd11c --- /dev/null +++ b/src/test/java/interactive/table/ColumnsSizeCacheTests.java @@ -0,0 +1,364 @@ +package interactive.table; + +import interactive.table.TableTestUtils.EmptyColumn; +import interactive.table.TableTestUtils.Table; +import interactive.table.TableTestUtils.User; +import io.github.palexdev.mfxcore.utils.RandomUtils; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.table.ColumnsSizeCache; +import io.github.palexdev.virtualizedfx.table.VFXTable; +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import javafx.beans.binding.DoubleBinding; +import javafx.collections.ObservableList; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testfx.api.FxRobot; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; + +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.util.Set; + +import static interactive.TestFXUtils.setupStage; +import static interactive.table.TableTestUtils.User.users; +import static interactive.table.TableTestUtils.setColumnWidth; +import static org.junit.jupiter.api.Assertions.*; +import static utils.Utils.setWindowSize; + +@ExtendWith(ApplicationExtension.class) +public class ColumnsSizeCacheTests { + + @Start + void start(Stage stage) { + stage.show(); + } + + @Test + void testColumnsSizeCache(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + DebuggableCache cache = new DebuggableCache(table); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert cache init + assertEquals(7, cache.size()); + assertFalse(cache.isValid()); + cache.assertAllInvalid(); + + // Validate the cache + assertEquals(180 * 7, cache.get()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Invalidate by increasing table's width + robot.interact(() -> setWindowSize(table, 1440, -1)); + assertFalse(cache.isValid()); + cache.assertInvalid(6); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180 * 6, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Change the min size + robot.interact(() -> table.setColumnsWidth(200)); + assertFalse(cache.isValid()); + cache.assertAllInvalid(); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 200 * 6, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Now change the width of a random column (before last) + int idx = RandomUtils.random.nextInt(0, table.getColumns().size() - 1); + robot.interact(() -> { + VFXTableColumn c = table.getColumns().get(idx); + c.resize(210); + }); + assertFalse(cache.isValid()); + cache.assertInvalid(idx, 6); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 200 * 6 - 10, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Now change the width of the last column + robot.interact(() -> { + VFXTableColumn c = table.getColumns().getLast(); + c.resize(400); + }); + assertFalse(cache.isValid()); + cache.assertInvalid(6); + + // Validate + assertEquals((200 * 6 + 10) + 400, cache.get()); + assertEquals(400, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + } + + @Test + void testColumnsSizeCacheLCL(FxRobot robot) { // LCL -> ListChangeListener + StackPane pane = setupStage(); + Table table = new Table(users(50)); + DebuggableCache cache = new DebuggableCache(table); + robot.interact(() -> { + setWindowSize(pane, 1440, -1); + pane.getChildren().add(table); + }); + + // Assert cache init + assertEquals(7, cache.size()); + assertFalse(cache.isValid()); + cache.assertAllInvalid(); + + // Validate before testing list changes + assertEquals(1440, cache.get()); + assertEquals(1440 - 180 * 6, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Remove random column (except last) + int rIdx = RandomUtils.random.nextInt(0, table.getColumns().size() - 1); + robot.interact(() -> table.getColumns().remove(rIdx)); + assertEquals(6, cache.size()); + assertFalse(cache.isValid()); + cache.assertInvalid(5); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180 * 5, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Add column at random (except last) + int aIdx = RandomUtils.random.nextInt(0, table.getColumns().size()); + robot.interact(() -> table.getColumns().add(aIdx, new EmptyColumn("Add " + aIdx, 0))); + assertEquals(7, cache.size()); + assertFalse(cache.isValid()); + cache.assertInvalid(aIdx, 6); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180 * 6, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Remove last + robot.interact(() -> table.getColumns().removeLast()); + assertEquals(6, cache.size()); + assertFalse(cache.isValid()); + cache.assertInvalid(5); + assertEquals(table.getColumns().getLast(), cache.getLastColumn()); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180 * 5, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Add last + robot.interact(() -> table.getColumns().addLast(new EmptyColumn("New Last", 999))); + assertEquals(7, cache.size()); + assertFalse(cache.isValid()); + cache.assertInvalid(5, 6); + assertEquals(table.getColumns().getLast(), cache.getLastColumn()); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180 * 6, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Clear + robot.interact(() -> table.getColumns().clear()); + assertEquals(0, cache.size()); + assertFalse(cache.isValid()); + assertNull(cache.getLastColumn()); + + // Validate + assertEquals(0, cache.get()); + assertTrue(cache.isValid()); + + // Add one + robot.interact(() -> table.getColumns().add(new EmptyColumn("First", 0))); + assertEquals(1, cache.size()); + assertFalse(cache.isValid()); + cache.assertInvalid(0); + assertEquals(table.getColumns().getLast(), cache.getLastColumn()); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Add another one + robot.interact(() -> table.getColumns().addLast(new EmptyColumn("Second", 1))); + assertEquals(2, cache.size()); + assertFalse(cache.isValid()); + cache.assertAllInvalid(); + assertEquals(table.getColumns().getLast(), cache.getLastColumn()); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // Remove first one + robot.interact(() -> table.getColumns().removeFirst()); + assertEquals(1, cache.size()); + assertFalse(cache.isValid()); + cache.assertAllInvalid(); + assertEquals(table.getColumns().getLast(), cache.getLastColumn()); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + + // This time add at 0 + robot.interact(() -> table.getColumns().addFirst(new EmptyColumn("Brrrr", -1))); + assertEquals(2, cache.size()); + assertFalse(cache.isValid()); + cache.assertAllInvalid(); + assertEquals(table.getColumns().getLast(), cache.getLastColumn()); + + // Validate + assertEquals(1440, cache.get()); + assertEquals(1440 - 180, cache.getLastColumnWidth()); + assertTrue(cache.isValid()); + cache.assertAllValid(); + } + + @Test + void testPosCache(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + DebuggableCache cache = new DebuggableCache(table); + robot.interact(() -> { + setWindowSize(pane, 1440, -1); + pane.getChildren().add(table); + }); + + // Try getting one in the middle + assertEquals(540, cache.getColumnPos(3)); + // Since the pos cache is built incrementally, check that previous columns have the right pos too + assertEquals(0, cache.getColumnPos(0)); + assertEquals(180, cache.getColumnPos(1)); + assertEquals(360, cache.getColumnPos(2)); + + // Change the width of column 1 + robot.interact(() -> setColumnWidth(table.getColumns().get(1), 200)); + assertEquals(0, cache.getColumnPos(0)); + cache.assertPosInvalid(1, 3); + // Notice how the assertions are scrambled + assertEquals(580, cache.getColumnPos(3)); + assertEquals(180, cache.getColumnPos(1)); + assertEquals(380, cache.getColumnPos(2)); + } + + @Test + void testColumnsSizeCacheDisposal(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + DebuggableCache cache = new DebuggableCache(table); + robot.interact(() -> { + setWindowSize(pane, 1440, -1); + pane.getChildren().add(table); + }); + + // Validate and Dispose + cache.get(); + cache.dispose(); + + // Try making some change + robot.interact(() -> table.getColumns().removeLast()); + assertTrue(cache.isValid()); + robot.interact(() -> table.setColumnsWidth(200)); + assertTrue(cache.isValid()); + robot.interact(() -> table.getColumns().getFirst().resize(210)); + assertTrue(cache.isValid()); + robot.interact(() -> setWindowSize(table, 800, -1)); + assertTrue(cache.isValid()); + + Reference ref = new WeakReference<>(cache); + cache = null; + System.gc(); + assertNull(ref.get()); + } + + //================================================================================ + // Internal Classes + //================================================================================ + static class DebuggableCache extends ColumnsSizeCache { + + public DebuggableCache(VFXTable table) { + super(table); + setWidthFunction(this::computeColumnWidth); + setXFunction(this::computeColumnPos); + init(); + } + + protected double computeColumnWidth(VFXTableColumn column, boolean isLast) { + VFXTable table = getTable(); + double minW = table.getColumnsSize().getWidth(); + double prefW = Math.max(column.prefWidth(-1), minW); + if (table.getColumns().size() == 1) return Math.max(prefW, table.getWidth()); + if (!isLast) return prefW; + + double partialW = getPartialWidth(); + return Math.max(prefW, table.getWidth() - partialW); + } + + protected double computeColumnPos(int index, double prevPos) { + VFXTable table = getTable(); + VFXTableColumn> column = table.getColumns().get(index); + return prevPos + getColumnWidth(column); + } + + void assertAllValid() { + assertTrue(getSizeCache().values().stream().allMatch(DoubleBinding::isValid)); + } + + void assertAllInvalid() { + assertTrue(getSizeCache().values().stream().noneMatch(DoubleBinding::isValid)); + } + + void assertInvalid(Integer... idxs) { + Set set = Set.of(idxs); + ObservableList>> columns = getTable().getColumns(); + for (int i = 0; i < columns.size(); i++) { + VFXTableColumn column = columns.get(i); + DoubleBinding binding = getSizeCache().get(column); + try { + assertEquals(set.contains(i), !binding.isValid()); + } catch (AssertionError e) { + System.err.printf("Assertion failed for %s/%d%n", column.getText(), i); + fail(e); + } + } + } + + void assertPosInvalid(int min, int max) { + for (int i = min; i <= max; i++) { + assertNull(getPositionCache().get(i)); + } + } + + @Override + public VFXTableColumn getLastColumn() { + return super.getLastColumn(); + } + } +} diff --git a/src/test/java/interactive/table/TableTestUtils.java b/src/test/java/interactive/table/TableTestUtils.java new file mode 100644 index 0000000..1b8953e --- /dev/null +++ b/src/test/java/interactive/table/TableTestUtils.java @@ -0,0 +1,641 @@ +package interactive.table; + +import interactive.TestFXUtils; +import interactive.TestFXUtils.Counter; +import io.github.palexdev.mfxcore.base.beans.Size; +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.mfxcore.controls.SkinBase; +import io.github.palexdev.mfxcore.utils.RandomUtils; +import io.github.palexdev.mfxcore.utils.fx.CSSFragment; +import io.github.palexdev.mfxcore.utils.fx.ColorUtils; +import io.github.palexdev.mfxcore.utils.fx.FXCollectors; +import io.github.palexdev.mfxresources.fonts.fontawesome.FontAwesomeSolid; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.enums.BufferSize; +import io.github.palexdev.virtualizedfx.enums.ColumnsLayoutMode; +import io.github.palexdev.virtualizedfx.table.*; +import io.github.palexdev.virtualizedfx.table.defaults.VFXDefaultTableColumn; +import io.github.palexdev.virtualizedfx.table.defaults.VFXDefaultTableRow; +import io.github.palexdev.virtualizedfx.table.defaults.VFXSimpleTableCell; +import io.github.palexdev.virtualizedfx.utils.Utils; +import javafx.collections.ObservableList; +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.util.StringConverter; +import net.datafaker.Faker; +import net.datafaker.providers.base.Name; +import org.opentest4j.AssertionFailedError; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.SequencedMap; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.IntStream; + +import static interactive.TestFXUtils.counter; +import static org.junit.jupiter.api.Assertions.*; + +public class TableTestUtils { + //================================================================================ + // Static Properties + //================================================================================ + public static final Counter rowsCounter = new TestFXUtils.Counter(); + + //================================================================================ + // Constructors + //================================================================================ + private TableTestUtils() {} + + //================================================================================ + // Methods + //================================================================================ + static void assertState(VFXTable table, IntegerRange rowsRange, IntegerRange columnsRange, int cellsNum) { + VFXTableState state = table.getState(); + VFXTableHelper helper = table.getHelper(); + Region columnsPane = (Region) table.lookup(".columns"); + Region rowsPane = (Region) table.lookup(".rows"); + if (Utils.INVALID_RANGE.equals(columnsRange)) { + assertEquals(VFXTableState.EMPTY, state); + // Also verify that there are no nodes in the viewport + assertTrue(columnsPane.getChildrenUnmodifiable().isEmpty()); + assertTrue(rowsPane.getChildrenUnmodifiable().isEmpty()); + return; + } + + boolean partial = cellsNum < helper.totalCells(); + + // Columns check + assertEquals(helper.columnsRange(), columnsRange); + + // Rows and cells checks + if (Utils.INVALID_RANGE.equals(rowsRange)) { + if (table.getRowFactory() != null && + !(table.getRowHeight() <= 0) && + !table.isEmpty()) { + fail("Invalid rows range, but why"); + } + assertTrue(state.isEmpty()); + assertEquals(0, state.cellsNum()); + assertTrue(rowsPane.getChildrenUnmodifiable().isEmpty()); + } else { + assertEquals(helper.rowsRange(), rowsRange); + assertEquals(rowsRange.diff() + 1, state.size()); + assertEquals(cellsNum, state.cellsNum()); + } + + ObservableList>> columns = table.getColumns(); + int j = 0; + for (Integer cIdx : columnsRange) { + try { + VFXTableColumn> column = columns.get(cIdx); + assertNotNull(column); + assertNotNull(column.getTable()); + assertEquals(cIdx, column.getIndex()); + assertNotNull(column.getParent()); // Assert that the column is actually in the viewport before checking the position + boolean inViewport = helper.isInViewport(column); + assertEquals(inViewport, column.isVisible()); + if (inViewport) { + assertLayout(table, j, column); + } else { + assertFalse(column.isVisible()); + } + } catch (Exception ex) { + fail(ex); + } + j++; + } + + ObservableList items = table.getItems(); + SequencedMap> rows = state.getRowsByIndexUnmodifiable(); + if (rows.isEmpty()) return; + int i = 0; + for (Integer rIdx : rowsRange) { + VFXTableRow row; + try { + row = rows.get(rIdx); + assertNotNull(row); + } catch (AssertionFailedError err) { + System.err.printf("Null row for index %d%n".formatted(rIdx)); + throw err; + } + + assertEquals(rIdx, row.getIndex()); + assertEquals(columnsRange, row.getColumnsRange()); + assertEquals(items.get(rIdx), row.getItem()); + assertLayout(table, i, row); + + SequencedMap> cells = row.getCellsUnmodifiable(); + j = 0; + for (Integer cIdx : columnsRange) { + TableCell cell = null; + try { + cell = cells.get(cIdx); + assertNotNull(cell); + } catch (AssertionFailedError err) { + System.err.printf("Null cell in row %d for column %d%n".formatted(rIdx, cIdx)); + if (!partial) throw err; + } + + if (cell instanceof VFXSimpleTableCell sCell) { + assertEquals(columns.get(cIdx), sCell.getColumn()); + assertEquals(row, sCell.getRow()); + assertEquals(cIdx, sCell.getIndex()); + if (!(sCell instanceof EmptyCell)) { + assertEquals(items.get(rIdx), sCell.getItem()); + } + assertEquals(sCell.isVisible(), sCell.getColumn().isVisible()); + if (helper.isInViewport(sCell.getColumn())) { + assertLayout(table, j, sCell); + } else { + assertFalse(sCell.isVisible()); + } + } else { + System.err.println("Cannot assert for cell of type: " + cell); + } + j++; + } + i++; + } + } + + static void assertState(VFXTable table, IntegerRange rowsRange, IntegerRange columnsRange) { + assertState(table, rowsRange, columnsRange, table.getHelper().totalCells()); + } + + static void assertRowsCounter(int created, int ixUpdates, int itUpdates, int deCached, int cached, int disposed) { + assertEquals(created, rowsCounter.created); + assertEquals(ixUpdates, rowsCounter.getUpdIndexCnt()); + assertEquals(itUpdates, rowsCounter.getUpdItemCnt()); + assertEquals(deCached, rowsCounter.getFromCache()); + assertEquals(cached, rowsCounter.getToCache()); + assertEquals(disposed, rowsCounter.getDisposed()); + rowsCounter.reset(); + } + + static void assertLayout(VFXTable table, int cIdx, VFXTableColumn> column) { + VFXTableHelper helper = table.getHelper(); + Bounds bounds = column.getBoundsInParent(); + double x = 0; + double w = helper.getColumnWidth(column); + double h = table.getColumnsSize().getHeight(); + if (table.getColumnsLayoutMode() == ColumnsLayoutMode.FIXED) { + x = cIdx * table.getColumnsSize().getWidth(); + } else { + for (VFXTableColumn> c : table.getColumns()) { + if (c == column) break; + x += c.getWidth(); + } + } + try { + assertEquals(x, bounds.getMinX()); + assertEquals(0, bounds.getMinY()); + assertEquals(w, bounds.getWidth()); + assertEquals(h, column.getLayoutBounds().getHeight()); // This would fail with `bounds` because there's the overlay node! + } catch (AssertionFailedError err) { + System.err.printf("Failed column layout assertion for column %s%n".formatted(column.getText())); + throw err; + } + } + + static void assertLayout(VFXTable table, int rIdx, VFXTableRow row) { + Bounds bounds = row.getBoundsInParent(); + double w = table.getVirtualMaxX(); + double h = table.getRowHeight(); + double y = h * rIdx; + try { + assertEquals(0, bounds.getMinX()); + assertEquals(y, bounds.getMinY()); + assertEquals(w, bounds.getWidth()); + assertEquals(h, bounds.getHeight()); + } catch (AssertionFailedError err) { + System.err.printf("Failed layout assertion for row at index %d%n".formatted(rIdx)); + throw err; + } + } + + static void assertLayout(VFXTable table, int layoutIdx, VFXSimpleTableCell cell) { + if (cell == null) return; + VFXTableHelper helper = table.getHelper(); + int cellIdx = cell.getIndex(); + ObservableList>> columns = table.getColumns(); + VFXTableColumn> column = columns.get(cellIdx); + Bounds bounds = cell.toNode().getBoundsInParent(); + + double x = 0; + double w = helper.getColumnWidth(column); + double h = table.getRowHeight(); + + if (table.getColumnsLayoutMode() == ColumnsLayoutMode.FIXED) { + x = table.getColumnsSize().getWidth() * layoutIdx; + } else { + for (int i = 0; i < cellIdx; i++) { + x += helper.getColumnWidth(columns.get(i)); + } + } + + try { + assertEquals(x, bounds.getMinX()); + assertEquals(0, bounds.getMinY()); + assertEquals(w, bounds.getWidth()); + assertEquals(h, bounds.getHeight()); + } catch (AssertionError err) { + System.err.printf("Failed cell layout assertion for column %s%n".formatted(column.getText())); + throw err; + } + } + + static void assertLength(VFXTable table, double vLength, double hLength) { + VFXTableHelper helper = table.getHelper(); + assertEquals(hLength, helper.getVirtualMaxX()); + assertEquals(vLength, helper.getVirtualMaxY()); + } + + static void assertScrollable(VFXTable table, double maxVScroll, double maxHScroll) { + VFXTableHelper helper = table.getHelper(); + assertEquals(maxVScroll, helper.maxVScroll()); + assertEquals(maxHScroll, helper.maxHScroll()); + } + + static void setRandomColumnWidth(VFXTable table, double w) { + setColumnWidth(RandomUtils.randFromList(table.getColumns()), w); + } + + static void setColumnWidth(VFXTableColumn column, double w) { + column.resize(w); + } + + //================================================================================ + // Internal Classes + //================================================================================ + public static class Table extends VFXTable { + private static int priority = 0; + + { + setColumnsSize(Size.of(180, 32)); + + CSSFragment.Builder.build() + .addSelector(".vfx-table") + .border("#353839") + .closeSelector() + .addSelector(".vfx-table > .viewport > .columns") + .border("transparent transparent #353839 transparent") + .closeSelector() + .addSelector(".vfx-table > .viewport > .columns > .vfx-column") + .padding("0px 10px 0px 10px") + .border("transparent #353839 transparent transparent") + .closeSelector() + .addSelector(".vfx-table > .viewport > .columns > .vfx-column:hover > .overlay") + .addSelector(".vfx-table > .viewport > .columns > .vfx-column:dragged > .overlay") + .background("rgba(53, 56, 57, 0.1)") + .closeSelector() + .addSelector(".vfx-table > .viewport > .rows > .vfx-row") + .border("#353839") + .borderInsets("1.25px") + .addStyle("-fx-border-width: 0.5px") + .closeSelector() + .addSelector(".vfx-table > .viewport > .rows > .vfx-row > .table-cell") + .padding("0px 10px 0px 10px") + .closeSelector() + .applyOn(this); + } + + public Table(ObservableList items) { + this(items, columns()); + } + + public Table(ObservableList items, Collection>> columns) { + super(items, columns); + } + + static Collection>> columns() { + int ICON_SIZE = 18; + Supplier ICON_COLOR = () -> ColorUtils.getRandomColor(0.7); + + TestColumn firstNameColumn = new TestColumn<>("First name", priority()); + firstNameColumn.setCellFactory(u -> factory(u, User::firstName)); + firstNameColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + TestColumn lastNameColumn = new TestColumn<>("Last name", priority()); + lastNameColumn.setCellFactory(u -> factory(u, User::lastName)); + lastNameColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + TestColumn birthColumn = new TestColumn<>("Birth year", priority()); + birthColumn.setCellFactory(u -> factory(u, User::birthYear)); + birthColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + TestColumn zodiacColumn = new TestColumn<>("Zodiac Sign", priority()); + zodiacColumn.setCellFactory(u -> factory(u, User::zodiac)); + zodiacColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + TestColumn countryColumn = new TestColumn<>("Country", priority()); + countryColumn.setCellFactory(u -> factory(u, User::country)); + countryColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + TestColumn bloodColumn = new TestColumn<>("Blood", priority()); + bloodColumn.setCellFactory(u -> factory(u, User::blood)); + bloodColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + TestColumn animalColumn = new TestColumn<>("Animal", priority()); + animalColumn.setCellFactory(u -> factory(u, User::animal)); + animalColumn.setGraphic(FontAwesomeSolid.random(ICON_COLOR.get(), ICON_SIZE)); + + return List.of(firstNameColumn, lastNameColumn, birthColumn, zodiacColumn, countryColumn, bloodColumn, animalColumn); + } + + static UserCell factory(User user, Function extractor) { + return factory(user, extractor, c -> {}); + } + + static UserCell factory(User user, Function extractor, Consumer> config) { + Function> f = u -> new UserCell<>(u, extractor); + f = f.andThen(c -> { + config.accept(c); + counter.created(); + return c; + }); + return f.apply(user); + } + + static int priority() { + return priority++; + } + + public static List emptyColumns(String baseText, int cnt) { + return IntStream.range(0, cnt) + .mapToObj(i -> new EmptyColumn(baseText + " " + (char) ('A' + i), priority())) + .toList(); + } + + public static List emptyColumns(int cnt) { + return emptyColumns("Empty", cnt); + } + + public Table addEmptyColumns(int cnt) { + getColumns().addAll(emptyColumns(cnt)); + return this; + } + + @Override + public void setBufferSize(BufferSize bufferSize) { + setRowsBufferSize(bufferSize); + setColumnsBufferSize(bufferSize); + } + + @Override + protected Function> defaultRowFactory() { + return TestRow::new; + } + + @Override + protected SkinBase buildSkin() { + return new VFXTableSkin<>(this) { + @Override + protected void onLayoutCompleted(boolean done) { + super.onLayoutCompleted(done); + if (done) counter.layout(); + } + }; + } + } + + public static class TestColumn extends VFXDefaultTableColumn> implements Comparable> { + private final int priority; + + public TestColumn(String text, int priority) { + super(text); + this.priority = priority; + } + + @Override + public int compareTo(TestColumn o) { + return Integer.compare(priority, o.priority); + } + } + + public static class TestRow extends VFXDefaultTableRow { + public TestRow(User item) { + super(item); + rowsCounter.created(); + } + + // TODO if super method changes this needs to be updated too (can we avoid this?) + @Override + protected void partialLayout(VFXTableColumn column, double oldW, double newW) { + VFXTable table = getTable(); + VFXTableHelper helper = table.getHelper(); + int cIndex = table.indexOf(column); + IntegerRange range = IntegerRange.of(cIndex, columnsRange.getMax()); + for (Integer idx : range) { + VFXTableColumn c = table.getColumns().get(idx); + if (!helper.isInViewport(c)) break; + + TableCell cell = cells.get(idx); + if (cell == null) continue; + Node node = cell.toNode(); + if (!node.isVisible()) { + helper.layoutCell(idx, node); + counter.layout(); + continue; + } + + if (idx == cIndex) { + node.resize(newW, getHeight()); + counter.layout(); + continue; + } + + double newX = helper.getColumnPos(idx, c); + node.relocate(newX, 0); + cell.toNode().setVisible(true); + counter.layout(); + } + } + + @Override + public void updateIndex(int index) { + super.updateIndex(index); + rowsCounter.index(); + } + + @Override + public void updateItem(User item) { + super.updateItem(item); + rowsCounter.item(); + } + + @Override + public void onDeCache() { + rowsCounter.fCache(); + } + + @Override + public void onCache() { + rowsCounter.tCache(); + } + + @Override + public void dispose() { + super.dispose(); + rowsCounter.disposed(); + } + } + + public static class UserCell extends VFXSimpleTableCell { + public UserCell(User item, Function extractor) { + super(item, extractor); + } + + public UserCell(User item, Function extractor, StringConverter converter) { + super(item, extractor, converter); + } + + @Override + public void updateIndex(int index) { + super.updateIndex(index); + counter.index(); + } + + @Override + public void updateItem(User item) { + super.updateItem(item); + counter.item(); + } + + @Override + public void onDeCache() { + counter.fCache(); + } + + @Override + public void onCache() { + counter.tCache(); + } + + @Override + public void dispose() { + counter.disposed(); + } + } + + public static class User { + private static int COUNT = 0; + private static final Faker faker = new Faker(); + private final int id; + private final String firstName; + private final String lastName; + private final int birthYear; + private final String zodiac; + private final String country; + private final String blood; + private final String animal; + + public User() { + Name name = faker.name(); + this.id = COUNT++; + this.firstName = name.firstName(); + this.lastName = name.lastName(); + this.birthYear = faker.number().numberBetween(1930, 2024); + this.zodiac = faker.zodiac().sign(); + this.country = faker.country().name(); + this.blood = faker.bloodtype().bloodGroup(); + this.animal = faker.animal().species(); + } + + public User(String firstName, String lastName, int birthYear) { + this.id = COUNT++; + this.firstName = firstName; + this.lastName = lastName; + this.birthYear = birthYear; + this.zodiac = ""; + this.country = ""; + this.blood = ""; + this.animal = ""; + } + + public static ObservableList users(int cnt) { + return IntStream.range(0, cnt) + .mapToObj(i -> new User()) + .collect(FXCollectors.toList()); + } + + public int id() { + return id; + } + + public String firstName() {return firstName;} + + public String lastName() {return lastName;} + + public int birthYear() {return birthYear;} + + public String zodiac() { + return zodiac; + } + + public String country() { + return country; + } + + public String blood() { + return blood; + } + + public String animal() { + return animal; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (User) obj; + return Objects.equals(this.firstName, that.firstName) && + Objects.equals(this.lastName, that.lastName) && + this.birthYear == that.birthYear; + } + + @Override + public int hashCode() { + return Objects.hash(firstName, lastName, birthYear); + } + + @Override + public String toString() { + return "User[" + + "firstName=" + firstName + ", " + + "lastName=" + lastName + ", " + + "birthYear=" + birthYear + ']'; + } + } + + public static class EmptyColumn extends TestColumn { + { + setCellFactory(item -> { + EmptyCell c = new EmptyCell(item, this); + counter.created(); + return c; + }); + } + + public EmptyColumn(String text, int priority) { + super(text, priority); + } + } + + public static class EmptyCell extends UserCell { + public EmptyCell(User item, EmptyColumn column) { + super(item, u -> ""); + setExtractor(u -> data(column)); + invalidate(); + } + + String data(EmptyColumn column) { + return "Column %s and Row %d".formatted(column.getText(), getIndex()); + } + } +} diff --git a/src/test/java/interactive/table/TableTests.java b/src/test/java/interactive/table/TableTests.java new file mode 100644 index 0000000..969143c --- /dev/null +++ b/src/test/java/interactive/table/TableTests.java @@ -0,0 +1,1788 @@ +package interactive.table; + +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import io.github.palexdev.mfxcore.utils.RandomUtils; +import io.github.palexdev.mfxcore.utils.fx.ColorUtils; +import io.github.palexdev.mfxcore.utils.fx.StyleUtils; +import io.github.palexdev.mfxeffects.animations.Animations.KeyFrames; +import io.github.palexdev.mfxeffects.animations.Animations.SequentialBuilder; +import io.github.palexdev.mfxeffects.animations.Animations.TimelineBuilder; +import io.github.palexdev.mfxeffects.enums.Interpolators; +import io.github.palexdev.virtualizedfx.cells.TableCell; +import io.github.palexdev.virtualizedfx.enums.BufferSize; +import io.github.palexdev.virtualizedfx.table.VFXTableColumn; +import io.github.palexdev.virtualizedfx.table.VFXTableHelper; +import io.github.palexdev.virtualizedfx.table.VFXTableState; +import javafx.animation.Animation; +import javafx.animation.Interpolator; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testfx.api.FxRobot; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; +import utils.Utils; + +import java.util.Collections; +import java.util.Comparator; + +import static interactive.TestFXUtils.*; +import static interactive.table.TableTestUtils.*; +import static interactive.table.TableTestUtils.Table.emptyColumns; +import static interactive.table.TableTestUtils.User.users; +import static io.github.palexdev.virtualizedfx.table.VFXTableColumn.swapColumns; +import static io.github.palexdev.virtualizedfx.utils.Utils.INVALID_RANGE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static utils.Utils.*; + +@ExtendWith(ApplicationExtension.class) +public class TableTests { + + /* + * How do counters work? + * In this note, I want to shed some light on the numbers you may see in these tests. + * There is a difference between the number of updates occurring and the number of updates issued. + * The first ones are the ones responsible for the cell's content to effectively change, e.g., a cell goes from item A to item B. + * The latter ones are invoked by the container's subsystem and may or may not end up changing the cell's content, + * e.g, cell goes from item C to item C -> even though the item is the same, the subsystem still issues the update, + * but it won't have any effect on the cell because of no invalidation (this also depends on the cell's implementation!) + * Why the subsystem issues "useless" updates then? + * Because not always it's possible for the subsystem to know whether a cell needs to be updated or not, it just assumes. + * This allows keeping the container's state stable, ensuring that each cell has the right properties set. + * Is there a performance cost for this? + * Yes and no. 1) It depends on the cell's implementation. If the cell is programmed to update only and only after + * an invalidation of its properties then, no, there is no significant hit on performance. 2) We are just calling setters + * after all, performance cost is negligible, even with a lot of cells + * + * So, the counters used by these tests will keep track of the "issued" updates to verify the correctness of the + * subsystem's algorithms + */ + + @Start + void start(Stage stage) { + stage.show(); + } + + @BeforeEach + void setup() { + // TODO add to other tests too + resetCounters(); + } + + @Test + void testInitAndGeometry(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Expand and test again + robot.interact(() -> setWindowSize(pane, 600, -1)); // Unchanged because columns are already at max + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 0, 0, 0, 0, 0, 0); + + robot.interact(() -> setWindowSize(pane, -1, 600)); + assertState(table, IntegerRange.of(0, 21), IntegerRange.of(0, 6)); + assertCounter(42, 1, 42, 42, 0, 0, 0); + assertRowsCounter(6, 6, 6, 0, 0, 0); + + // Shrink and test again + robot.interact(() -> setWindowSize(pane, 300, -1)); + assertState(table, IntegerRange.of(0, 21), IntegerRange.of(0, 5)); + assertCounter(0, 1, 0, 0, 0, 22, 12); // 22 for the same column + + robot.interact(() -> setWindowSize(pane, -1, 300)); + assertState(table, IntegerRange.of(0, 12), IntegerRange.of(0, 5)); + assertCounter(0, 1, 0, 0, 0, 54, 0); // 54 for different columns. 9 for each. No disposals. + assertRowsCounter(0, 0, 0, 0, 9, 0); + + robot.interact(() -> setWindowSize(pane, -1, 500)); + assertState(table, IntegerRange.of(0, 18), IntegerRange.of(0, 5)); + assertCounter(0, 1, 36, 36, 36, 0, 0); + assertRowsCounter(0, 6, 6, 6, 0, 0); + + // Edge case set width to 0 + robot.interact(() -> { + table.setMinWidth(0); + table.setPrefWidth(0); + table.setMaxWidth(0); + robot.interact(() -> setWindowSize(pane, 0, -1)); + }); + assertState(table, INVALID_RANGE, INVALID_RANGE); + assertCounter(0, 0, 0, 0, 0, 114, 72); // Each column already has 3 cells in cache, so 54 + 18 = 72 + assertRowsCounter(0, 0, 0, 0, 19, 12); // 12 disposed because 3 already in cache + } + + @Test + void testInitAndGeometryMaxX(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> { + pane.getChildren().add(table); + table.scrollToLastColumn(); + }); + + // Check hPos!! + assertEquals(860.0, table.getHPos()); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Expand (won't have any effect since columns are already all shown) + robot.interact(() -> setWindowSize(pane, 600, -1)); + assertEquals(660.0, table.getHPos()); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 0, 0, 0, 0, 0, 0); + + // Shrink + robot.interact(() -> { + robot.interact(() -> setWindowSize(pane, 100, -1)); + // Why you be like that JavaFX :smh: + table.setMinWidth(100.0); + table.setPrefWidth(100.0); + table.setMaxWidth(100.0); + }); + assertEquals(660.0, table.getHPos()); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(1, 5)); // We are at hPos 660.0. floor(660 / 180) = 3 as first visible column + assertCounter(0, 1, 0, 0, 0, 32, 12); + } + + @Test + void testInitAndGeometryMaxY(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> { + pane.getChildren().add(table); + table.scrollToLastRow(); + }); + + // Check vPos!! + assertEquals(1232.0, table.getVPos()); + + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Expand + robot.interact(() -> setWindowSize(pane, -1, 600)); + assertEquals(1032.0, table.getVPos()); + assertState(table, IntegerRange.of(28, 49), IntegerRange.of(0, 6)); + assertCounter(42, 1, 42, 42, 0, 0, 0); + assertRowsCounter(6, 6, 6, 0, 0, 0); + + // Shrink + robot.interact(() -> setWindowSize(pane, -1, 300)); + assertEquals(1032.0, table.getVPos()); + assertState(table, IntegerRange.of(30, 42), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 0, 0, 63, 0); + assertRowsCounter(0, 0, 0, 0, 9, 0); + } + + @Test + void testLastColumnResize(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Expand + robot.interact(() -> setWindowSize(pane, 1600, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + + // Shrink a bit + robot.interact(() -> setWindowSize(pane, 1300, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + + // Shrink to the right size + robot.interact(() -> setWindowSize(pane, 1260, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + + // Shrink again a no layouts should occur + robot.interact(() -> setWindowSize(pane, 800, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 0, 0, 0, 0, 0, 0); + } + + @Test + void testPopulateCache(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + table.populateCache(false); + resetCounters(); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(6, 16, 16, 10, 0, 0); + } + + @Test + void testPopulateCacheAll(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + table.populateCache(true); + resetCounters(); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(42, 1, 112, 112, 70, 0, 0); + assertRowsCounter(6, 16, 16, 10, 0, 0); + } + + @Test + void testScrollVertical(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + robot.interact(() -> table.setVPos(400.0)); + assertState(table, IntegerRange.of(10, 25), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 70, 0, 0, 0); + assertRowsCounter(0, 10, 10, 0, 0, 0); + + robot.interact(table::scrollToLastRow); + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 112, 0, 0, 0); + assertRowsCounter(0, 16, 16, 0, 0, 0); + + robot.interact(() -> { + table.setItems(users(35)); + table.scrollToFirstRow(); // Here range becomes [0, 15] + counter.reset(); + rowsCounter.reset(); + table.setVPos(300.0); + }); + assertState(table, IntegerRange.of(7, 22), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 49, 0, 0, 0); + assertRowsCounter(0, 7, 7, 0, 0, 0); + } + + @Test + void testScrollHorizontal(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(10); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + robot.interact(() -> table.setHPos(800.0)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(2, 8)); + assertCounter(32, 1, 32, 32, 0, 32, 12); + + robot.interact(table::scrollToLastColumn); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(10, 16)); + assertCounter(112, 1, 112, 112, 0, 112, 42); + + // The following test is complex but interesting because we are also removing columns from the table + // For this reason I'm going to test every step + robot.interact(() -> table.getColumns().remove(10, 16)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(4, 10)); + assertCounter(46, 1, 112, 96, 50, 96, 36); + + robot.interact(table::scrollToFirstColumn); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(24, 1, 64, 64, 40, 64, 24); + + robot.interact(() -> table.setHPos(600.0)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(1, 7)); + assertCounter(6, 1, 16, 16, 10, 16, 6); + } + + @Test + void testScrollHorizontalNoItems(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(0)) + .addEmptyColumns(10); + VFXTableHelper helper = table.getHelper(); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, INVALID_RANGE, IntegerRange.of(0, 6), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + Animation a1 = TimelineBuilder.build() + .add(KeyFrames.of(500, table.hPosProperty(), 800, Interpolator.LINEAR)) + .getAnimation(); + robot.interact(a1::play); + sleep(550); + assertState(table, INVALID_RANGE, IntegerRange.of(2, 8), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + + Animation a2 = TimelineBuilder.build() + .add(KeyFrames.of(500, table.hPosProperty(), helper.maxHScroll(), Interpolators.LINEAR)) + .getAnimation(); + robot.interact(a2::play); + sleep(550); + assertState(table, INVALID_RANGE, IntegerRange.of(10, 16), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + + // The following test is complex but interesting because we are also removing columns from the table + // For this reason I'm going to test every step + robot.interact(() -> table.getColumns().remove(10, 16)); + assertState(table, INVALID_RANGE, IntegerRange.of(4, 10), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + + robot.interact(table::scrollToFirstColumn); + assertState(table, INVALID_RANGE, IntegerRange.of(0, 6), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + + robot.interact(() -> table.setHPos(600.0)); + assertState(table, INVALID_RANGE, IntegerRange.of(1, 7), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + } + + @Test + void testBufferChangeTopLeft(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + VFXTableHelper helper = table.getHelper(); + robot.interact(() -> pane.getChildren().add(table)); + + // Medium Buffer + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertEquals(36, helper.visibleCells()); + assertEquals(112, helper.totalCells()); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Small Buffer + robot.interact(() -> table.setBufferSize(BufferSize.SMALL)); + assertState(table, IntegerRange.of(0, 13), IntegerRange.of(0, 4)); + assertEquals(36, helper.visibleCells()); + assertEquals(70, helper.totalCells()); + assertCounter(0, 2, 0, 0, 0, 42, 12); + assertRowsCounter(0, 0, 0, 0, 2, 0); + + // Big buffer + robot.interact(() -> table.setBufferSize(BufferSize.BIG)); + assertState(table, IntegerRange.of(0, 17), IntegerRange.of(0, 8)); + assertEquals(36, helper.visibleCells()); + assertEquals(162, helper.totalCells()); + assertCounter(62, 2, 92, 92, 30, 0, 0); + assertRowsCounter(2, 4, 4, 2, 0, 0); + } + + @Test + void testBufferChangeMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + VFXTableHelper helper = table.getHelper(); + robot.interact(() -> { + table.setVPos(616.0); + table.setHPos(1240.0); + pane.getChildren().add(table); + }); + + // Medium buffer + assertState(table, IntegerRange.of(17, 32), IntegerRange.of(4, 10)); + assertEquals(36, helper.visibleCells()); + assertEquals(112, helper.totalCells()); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Small buffer + robot.interact(() -> table.setBufferSize(BufferSize.SMALL)); + assertState(table, IntegerRange.of(18, 31), IntegerRange.of(5, 9)); + assertEquals(36, helper.visibleCells()); + assertEquals(70, helper.totalCells()); + assertCounter(0, 2, 0, 0, 0, 42, 12); + assertRowsCounter(0, 0, 0, 0, 2, 0); + + + // Big buffer + robot.interact(() -> table.setBufferSize(BufferSize.BIG)); + assertState(table, IntegerRange.of(16, 33), IntegerRange.of(3, 11)); + assertEquals(36, helper.visibleCells()); + assertEquals(162, helper.totalCells()); + assertCounter(62, 2, 92, 92, 30, 0, 0); + assertRowsCounter(2, 4, 4, 2, 0, 0); + } + + @Test + void testBufferChangeBottomRight(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + VFXTableHelper helper = table.getHelper(); + robot.interact(() -> { + table.scrollToLastRow(); + table.scrollToLastColumn(); + pane.getChildren().add(table); + }); + + // Medium buffer + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(9, 15)); + assertEquals(36, helper.visibleCells()); + assertEquals(112, helper.totalCells()); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Small buffer + robot.interact(() -> table.setBufferSize(BufferSize.SMALL)); + assertState(table, IntegerRange.of(36, 49), IntegerRange.of(11, 15)); + assertEquals(36, helper.visibleCells()); + assertEquals(70, helper.totalCells()); + assertCounter(0, 2, 0, 0, 0, 42, 12); + assertRowsCounter(0, 0, 0, 0, 2, 0); + + // Big buffer + robot.interact(() -> table.setBufferSize(BufferSize.BIG)); + assertState(table, IntegerRange.of(32, 49), IntegerRange.of(7, 15)); + assertEquals(36, helper.visibleCells()); + assertEquals(162, helper.totalCells()); + assertCounter(62, 2, 92, 92, 30, 0, 0); + assertRowsCounter(2, 4, 4, 2, 0, 0); + } + + @Test + void testChangeRowFactory(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + VFXTableHelper helper = table.getHelper(); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Test at both pos != 0 + robot.interact(() -> { + table.setVPos(600.0); + table.setHPos(1000.0); + }); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertCounter(48, 2, 48, 160, 0, 48, 18); // Counter's stats are bigger because we scroll two times + assertRowsCounter(0, 16, 16, 0, 0, 0); + + // Change row factory and test + robot.interact(() -> + table.setRowFactory(u -> new TestRow(u) { + { + StyleUtils.setBackground(this, ColorUtils.getRandomColor(0.2)); + } + }) + ); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertEquals(36, helper.visibleCells()); + assertEquals(112, helper.totalCells()); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 16, 16); + } + + @Test + void testChangeRowFactory2(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Test at both pos != 0 + robot.interact(() -> { + table.setVPos(600.0); + table.setHPos(1000.0); + }); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertCounter(48, 2, 48, 160, 0, 48, 18); // Counter's stats are bigger because we scroll two times + assertRowsCounter(0, 16, 16, 0, 0, 0); + + // Change row factory to null + robot.interact(() -> table.setRowFactory(null)); + assertState(table, INVALID_RANGE, IntegerRange.of(3, 9)); + assertCounter(0, 0, 0, 0, 0, 112, 42); + assertRowsCounter(0, 0, 0, 0, 16, 16); + + robot.interact(() -> + table.setRowFactory(u -> new TestRow(u) { + { + StyleUtils.setBackground(this, ColorUtils.getRandomColor(0.2)); + } + }) + ); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertCounter(42, 1, 112, 112, 70, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + } + + @SuppressWarnings("unchecked") // Fuck Java generics + @Test + void testChangeCellFactory(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Test at both pos != 0 + robot.interact(() -> { + table.setVPos(600.0); + table.setHPos(1000.0); + }); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertCounter(48, 2, 48, 160, 0, 48, 18); // Counter's stats are bigger because we scroll two times + assertRowsCounter(0, 16, 16, 0, 0, 0); + + // Change cell factory of column 5 + robot.interact(() -> { + VFXTableColumn> column = (VFXTableColumn>) table.getColumns().get(5); + column.setCellFactory(u -> Table.factory(u, User::blood, c -> StyleUtils.setBackground(c, ColorUtils.getRandomColor(0.2)))); + }); + VFXTableState c1State = table.getState(); + assertTrue(c1State.isClone()); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertCounter(16, 0, 16, 16, 0, 16, 16); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Change cell factory of column 5 to null + robot.interact(() -> { + VFXTableColumn> column = (VFXTableColumn>) table.getColumns().get(5); + column.setCellFactory(null); + }); + assertEquals(c1State, table.getState()); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9), 96); + assertCounter(0, 0, 0, 0, 0, 16, 16); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Scroll and verify everything is fine + long duration = 500; + Animation scroll = SequentialBuilder.build() + .add(KeyFrames.of(duration, table.vPosProperty(), 0.0, Interpolator.LINEAR)) + .add(KeyFrames.of(duration, table.vPosProperty(), 600.0, Interpolators.LINEAR)) + .add(KeyFrames.of(duration, table.hPosProperty(), 0.0, Interpolators.LINEAR)) + .add(KeyFrames.of(duration, table.hPosProperty(), 1000.0, Interpolators.LINEAR)) + .getAnimation(); + robot.interact(scroll::play); + sleep(duration * 4 + 200); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9), 96); + + // Cause empty state and try changing cell factory again + resetCounters(); // Reset counters before since we are not testing them above + robot.interact(() -> table.setRowHeight(0.0)); + assertState(table, INVALID_RANGE, IntegerRange.of(3, 9)); + assertCounter(0, 0, 0, 0, 0, 96, 36); + assertRowsCounter(0, 0, 0, 0, 16, 6); + + // Now change factory, nothing should happen + robot.interact(() -> { + VFXTableColumn> column = (VFXTableColumn>) table.getColumns().get(5); + column.setCellFactory(u -> Table.factory(u, User::blood, c -> StyleUtils.setBackground(c, ColorUtils.getRandomColor(0.2)))); + }); + assertState(table, INVALID_RANGE, IntegerRange.of(3, 9)); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + } + + @Test + void testChangeCellHeightTopLeft(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Decrease and test + robot.interact(() -> table.setRowHeight(20.0)); + assertState(table, IntegerRange.of(0, 22), IntegerRange.of(0, 6)); + assertCounter(49, 1, 49, 49, 0, 0, 0); + assertRowsCounter(7, 7, 7, 0, 0, 0); + assertLength(table, 50.0 * 20, 16 * 180); + + // Increase and test + robot.interact(() -> table.setRowHeight(50)); + assertState(table, IntegerRange.of(0, 11), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 0, 0, 77, 7); + assertLength(table, 50 * 50, 16 * 180); + } + + @Test + void testChangeCellHeightMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> { + pane.getChildren().add(table); + table.setVPos(600.0); + table.setHPos(1000.0); + }); + + // Check positions!!! + assertEquals(600, table.getVPos()); + assertEquals(1000, table.getHPos()); + + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(3, 9)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Decrease and test + robot.interact(() -> table.setRowHeight(18.0)); + assertEquals(532, table.getVPos()); + assertEquals(1000, table.getHPos()); + assertState(table, IntegerRange.of(25, 49), IntegerRange.of(3, 9)); + assertCounter(63, 1, 63, 126, 0, 0, 0); + assertRowsCounter(9, 18, 18, 0, 0, 0); + assertLength(table, 50.0 * 18, 16 * 180); + // 25 rows. 7 in common and 9 reusable. 25 - 7 - 9 = 9 new rows + // 9 * 7 = 63 new cells + // (25 rows - 7 in common) * 7 columns = 126 cell updates (only items!!) + // The index updates are only for new cells or column change + + // Increase and test + robot.interact(() -> table.setRowHeight(40.0)); + assertEquals(532, table.getVPos()); + assertEquals(1000, table.getHPos()); + assertState(table, IntegerRange.of(11, 24), IntegerRange.of(3, 9)); + assertCounter(0, 1, 0, 98, 0, 77, 7); + assertRowsCounter(0, 14, 14, 0, 11, 1); + assertLength(table, 50.0 * 40, 16 * 180); + } + + @Test + void testChangeCellHeightBottomRight(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> { + pane.getChildren().add(table); + table.scrollToLastRow(); + table.scrollToLastColumn(); + }); + + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(9, 15)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Check positions!!! + assertEquals(1232, table.getVPos()); + assertEquals(2480, table.getHPos()); + + // Decrease and test + robot.interact(() -> table.setRowHeight(25.0)); + assertEquals(882, table.getVPos()); + assertEquals(2480, table.getHPos()); + assertState(table, IntegerRange.of(31, 49), IntegerRange.of(9, 15)); + assertCounter(21, 1, 21, 21, 0, 0, 0); + assertRowsCounter(3, 3, 3, 0, 0, 0); + + // Increase and test + robot.interact(() -> table.setRowHeight(44.0)); + assertEquals(882, table.getVPos()); + assertEquals(2480, table.getHPos()); + assertState(table, IntegerRange.of(18, 30), IntegerRange.of(9, 15)); + assertCounter(0, 1, 0, 91, 0, 42, 0); + assertRowsCounter(0, 13, 13, 0, 6, 0); + } + + @Test + void testChangeColumnsSizeTopLeft(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> { + // Let's start from a height >= 48 otherwise things get complicated for the decrease test + table.setColumnsSize(180, 52); + pane.getChildren().add(table); + }); + + assertState(table, IntegerRange.of(0, 14), IntegerRange.of(0, 6)); + assertCounter(105, 1, 105, 105, 0, 0, 0); + assertRowsCounter(15, 15, 15, 0, 0, 0); + + // Decrease and test + robot.interact(() -> table.setColumnsSize(100, 24)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 7)); + assertCounter(23, 1, 23, 23, 0, 0, 0); + assertRowsCounter(1, 1, 1, 0, 0, 0); + + // Increase and test + robot.interact(() -> table.setColumnsSize(240, 100)); + assertState(table, IntegerRange.of(0, 13), IntegerRange.of(0, 5)); + assertCounter(0, 1, 0, 0, 0, 44, 12); + assertRowsCounter(0, 0, 0, 0, 2, 0); + + // Change, do not cause any actual change + robot.interact(() -> table.setColumnsSize(230, 90)); + assertState(table, IntegerRange.of(0, 13), IntegerRange.of(0, 5)); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + } + + @Test + void testChangeColumnsSizeMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> { + // Let's start from a height >= 48 otherwise things get complicated for the decrease test + table.setColumnsSize(180, 52); + table.setVPos(600.0); + table.setHPos(1000.0); + pane.getChildren().add(table); + }); + + // Check positions!!! + assertEquals(600.0, table.getVPos()); + assertEquals(1000.0, table.getHPos()); + + assertState(table, IntegerRange.of(16, 30), IntegerRange.of(3, 9)); + assertCounter(105, 1, 105, 105, 0, 0, 0); + assertRowsCounter(15, 15, 15, 0, 0, 0); + assertScrollable(table, 50 * 32 - 400 + 52, 180 * 16 - 400); + + // Decrease and test + robot.interact(() -> table.setColumnsSize(100, 24)); + assertEquals(600.0, table.getVPos()); + assertEquals(1000.0, table.getHPos()); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(8, 15)); + assertCounter(98, 1, 98, 98, 0, 75, 25); + assertRowsCounter(1, 1, 1, 0, 0, 0); + assertScrollable(table, 50 * 32 - 400 + 24, 100 * 16 - 400); + // Total cells needed 16 rows * 8 columns = 128 + // 2 columns in common. 16 rows * 6 columns = 96 new cells + // 1 new row, which means that those 2 columns in common still need 2 new cells + // Total new cells = 96 + 2 = 98 updates (128 total cells - 32 in common + 2 new from commons) + + // Increase and test + robot.interact(() -> table.setColumnsSize(240, 100)); + assertEquals(600.0, table.getVPos()); + assertEquals(1000.0, table.getHPos()); + assertState(table, IntegerRange.of(16, 29), IntegerRange.of(2, 7)); + assertCounter(34, 1, 84, 84, 50, 128, 48); + assertRowsCounter(0, 0, 0, 0, 2, 0); + assertScrollable(table, 50 * 32 - 400 + 100, 240 * 16 - 400); + // Total cells needed 14 rows * 6 columns = 84 cells + // Columns [3, 7] were already shown before, and some of their cells are now in cache (cache is full) + // Created cells = 84 - (5 * 10 from each cache) = 34 new cells + // Both new and de-cached cells need to be updated though, so we have 84 updates anyway + // There are no columns in common with the previous range which means that cells for columns [8, 15] are all cached + // 128 in cache, 16 for each column, max capacity is 10 so, 6 disposals for each column; 6 * 8 = 48 cells disposed + + // Change, do not cause any actual change + robot.interact(() -> table.setColumnsSize(230, 90)); + assertEquals(600.0, table.getVPos()); + assertEquals(1000.0, table.getHPos()); + assertState(table, IntegerRange.of(16, 29), IntegerRange.of(2, 7)); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + assertScrollable(table, 50 * 32 - 400 + 90, 230 * 16 - 400); + } + + @Test + void testChangeColumnsSizeBottomRight(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> { + // Let's start from a height >= 48 otherwise things get complicated for the decrease test + table.setColumnsSize(180, 52); + table.scrollToLastRow(); + table.scrollToLastColumn(); + pane.getChildren().add(table); + }); + + // Check positions!!! + assertEquals(1252.0, table.getVPos()); + assertEquals(2480.0, table.getHPos()); + + assertState(table, IntegerRange.of(35, 49), IntegerRange.of(9, 15)); + assertCounter(105, 1, 105, 105, 0, 0, 0); + assertRowsCounter(15, 15, 15, 0, 0, 0); + assertScrollable(table, 50 * 32 - 400 + 52, 180 * 16 - 400); + + // Decrease and test + robot.interact(() -> table.setColumnsSize(100, 24)); + assertEquals(1224.0, table.getVPos()); + assertEquals(1200.0, table.getHPos()); + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(8, 15)); + assertCounter(23, 1, 23, 23, 0, 0, 0); + assertRowsCounter(1, 1, 1, 0, 0, 0); + assertScrollable(table, 50 * 32 - 400 + 24, 100 * 16 - 400); + + // Increase and test + robot.interact(() -> table.setColumnsSize(240, 100)); + assertEquals(1224.0, table.getVPos()); + assertEquals(1200.0, table.getHPos()); + assertState(table, IntegerRange.of(36, 49), IntegerRange.of(3, 8)); + assertCounter(70, 1, 70, 70, 0, 114, 42); + assertRowsCounter(0, 0, 0, 0, 2, 0); + assertScrollable(table, 50 * 32 - 400 + 100, 240 * 16 - 400); + + // Change, do not cause any actual change + robot.interact(() -> table.setColumnsSize(230, 90)); + assertEquals(1224.0, table.getVPos()); + assertEquals(1200.0, table.getHPos()); + assertState(table, IntegerRange.of(36, 49), IntegerRange.of(3, 8)); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + assertScrollable(table, 50 * 32 - 400 + 90, 230 * 16 - 400); + } + + @Test + void testChangeColumnsSizeSeparately(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(9); + robot.interact(() -> { + // Let's start from a height >= 48 otherwise things get complicated for the decrease test + table.setColumnsSize(180, 52); + pane.getChildren().add(table); + }); + + assertState(table, IntegerRange.of(0, 14), IntegerRange.of(0, 6)); + assertCounter(105, 1, 105, 105, 0, 0, 0); + assertRowsCounter(15, 15, 15, 0, 0, 0); + + // Decrease height + robot.interact(() -> table.setColumnsHeight(24)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(7, 1, 7, 7, 0, 0, 0); + assertRowsCounter(1, 1, 1, 0, 0, 0); + + // Decrease width + robot.interact(() -> table.setColumnsWidth(100)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 7)); + assertCounter(16, 1, 16, 16, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Increase height + robot.interact(() -> table.setColumnsHeight(100)); + assertState(table, IntegerRange.of(0, 13), IntegerRange.of(0, 7)); + assertCounter(0, 1, 0, 0, 0, 16, 0); + assertRowsCounter(0, 0, 0, 0, 2, 0); + + // Increase width + robot.interact(() -> table.setColumnsWidth(240)); + assertState(table, IntegerRange.of(0, 13), IntegerRange.of(0, 5)); + assertCounter(0, 1, 0, 0, 0, 28, 12); + assertRowsCounter(0, 0, 0, 0, 0, 0); + } + + @Test + void testChangeColumnsSizeSeparatelyNoItems(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(0)) + .addEmptyColumns(9); + robot.interact(() -> { + // Let's start from a height >= 48 otherwise things get complicated for the decrease test + table.setColumnsSize(180, 52); + pane.getChildren().add(table); + }); + + assertState(table, INVALID_RANGE, IntegerRange.of(0, 6), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Decrease height + robot.interact(() -> table.setColumnsHeight(24)); + assertState(table, INVALID_RANGE, IntegerRange.of(0, 6), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Decrease width + robot.interact(() -> table.setColumnsWidth(100)); + assertState(table, INVALID_RANGE, IntegerRange.of(0, 7), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Increase height + robot.interact(() -> table.setColumnsHeight(100)); + assertState(table, INVALID_RANGE, IntegerRange.of(0, 7), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // Increase width + robot.interact(() -> table.setColumnsWidth(240)); + assertState(table, INVALID_RANGE, IntegerRange.of(0, 5), 0); + assertCounter(0, 0, 0, 0, 0, 0, 0); + assertRowsCounter(0, 0, 0, 0, 0, 0); + + // See Table class for why there are no layouts + } + + @Test + void testColumnsPermutation(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + + // Permutation change + robot.interact(() -> FXCollections.sort(table.getColumns(), Collections.reverseOrder())); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 112, 42); + } + + @Test + void testColumnsPermutationMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> { + table.setHPos(1000.0); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + + // Permutation change + robot.interact(() -> FXCollections.sort(table.getColumns(), Collections.reverseOrder())); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(48, 1, 112, 48, 0, 48, 18); + } + + @Test + void testSetColumns(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Set all (more) + robot.interact(() -> table.getColumns().setAll(emptyColumns("Set", 15))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 112, 42); + + // Set all (less) + robot.interact(() -> table.getColumns().setAll(emptyColumns("Set", 5))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 4)); + assertCounter(80, 1, 80, 80, 0, 112, 42); + + // Restore old count + // Go to last and set all (more) + robot.interact(() -> { + table.getColumns().setAll(emptyColumns(16)); + table.scrollToLastColumn(); + }); + resetCounters(); + robot.interact(() -> table.getColumns().setAll(emptyColumns("Set", 30))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(11, 17)); + assertCounter(112, 1, 112, 112, 0, 112, 42); + + // Set random columns + robot.interact(() -> { + IntegerRange columnsRange = table.getState().getColumnsRange(); + for (int i = 0; i < 5; i++) { + int index = RandomUtils.random.nextInt(columnsRange.getMin(), columnsRange.getMax() + 1); + table.getColumns().set(index, new EmptyColumn("Random", 999 - i)); + } + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(11, 17)); + assertCounter(80, 5, 560, 80, 0, 80, 30); + // Every time a change occurs, all the cells have their index updated. + // This is because their parent column may be in a different position after the change. + // This should be improved on the cell implementation side with a basic check. + // There are a total of 112 cells in the viewport, for 5 changes... 112 * 5 = 560 + // Also remember... cells here CANNOT be reused as every column produces its own kind + } + + @Test + void testAddColumnsAt0(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Add all at 0 + robot.interact(() -> table.getColumns().addAll(0, emptyColumns("Added", 4))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(64, 1, 112, 64, 0, 64, 24); + + // Add at 0 (for) + robot.interact(() -> { + for (int i = 0; i < 4; i++) table.getColumns().addFirst(new EmptyColumn("Add for", 999 - i)); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(64, 4, 448, 64, 0, 64, 24); + } + + @Test + void testAddColumnsAtMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> { + table.setHPos(900.0); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Add before no intersect + robot.interact(() -> table.getColumns().addAll(0, emptyColumns("Add bni", 2))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(32, 1, 112, 32, 0, 32, 12); + + // Add before intersect + robot.interact(() -> table.getColumns().addAll(2, emptyColumns("Add bi", 3))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(48, 1, 112, 48, 0, 48, 18); + + // Add after intersect + robot.interact(() -> table.getColumns().addAll(9, emptyColumns("Add ai", 2))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(16, 1, 112, 16, 0, 16, 6); + + // Add after no intersect + robot.interact(() -> table.getColumns().addAll(10, emptyColumns("Add ani", 2))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(3, 9)); + assertCounter(0, 1, 112, 0, 0, 0, 0); + } + + @Test + void testAddColumnsAtEnd(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> { + table.scrollToLastColumn(); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(9, 15)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Add all at end + robot.interact(() -> table.getColumns().addAll(emptyColumns("End", 2))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(11, 17)); + assertCounter(32, 1, 112, 32, 0, 32, 12); + + // Add all at end (for) + robot.interact(() -> { + for (int i = 0; i < 2; i++) + table.getColumns().add(new EmptyColumn("EndFor", 999 - i)); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(11, 17)); + assertCounter(0, 2, 224, 0, 0, 0, 0); + + // Add before no intersect + robot.interact(() -> table.getColumns().addAll(0, emptyColumns("Add bni", 2))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(11, 17)); + assertCounter(12, 1, 112, 32, 20, 32, 12); + + // Add before intersect + robot.interact(() -> table.getColumns().addAll(10, emptyColumns("Add bi", 3))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(11, 17)); + assertCounter(48, 1, 112, 48, 0, 48, 18); + } + + @Test + void testRemoveColumnsAt0(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(9); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Remove all at 0 + robot.interact(() -> Utils.removeAll(table.getColumns(), 0, 1, 2)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(48, 1, 112, 48, 0, 48, 18); + + // Remove at 0 (for) + robot.interact(() -> { + for (int i = 0; i < 4; i++) table.getColumns().removeFirst(); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(64, 4, 448, 64, 0, 64, 24); + + // Clear and assert EMPTY state + robot.interact(() -> table.getColumns().clear()); + assertState(table, INVALID_RANGE, INVALID_RANGE); + assertCounter(0, 0, 0, 0, 0, 112, 42); + assertEquals(0.0, table.getHPos()); + } + + @Test + void testRemoveColumnsAtMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(15); + robot.interact(() -> { + table.setHPos(1600.0); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(6, 12)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Remove before no intersect + robot.interact(() -> Utils.removeAll(table.getColumns(), 0, 1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(6, 12)); + assertCounter(32, 1, 112, 32, 0, 32, 12); + + // Remove before intersect + robot.interact(() -> Utils.removeAll(table.getColumns(), 5, 6)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(6, 12)); + assertCounter(32, 1, 112, 32, 0, 32, 12); + + // Remove after intersect + robot.interact(() -> Utils.removeAll(table.getColumns(), 12, 13)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(6, 12)); + assertCounter(16, 1, 112, 16, 0, 16, 6); + + // Remove after no intersect + robot.interact(() -> Utils.removeAll(table.getColumns(), 13, 14)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(6, 12)); + assertCounter(0, 1, 112, 0, 0, 0, 0); + + // Clear and assert EMPTY state + robot.interact(() -> table.getColumns().clear()); + assertState(table, INVALID_RANGE, INVALID_RANGE); + assertCounter(0, 0, 0, 0, 0, 112, 42); + assertEquals(0.0, table.getHPos()); + } + + @Test + void testRemoveColumnsAtEnd(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(20)) + .addEmptyColumns(15); + robot.interact(() -> { + table.scrollToLastColumn(); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(15, 21)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Remove all at end + robot.interact(() -> Utils.removeAll(table.getColumns(), 19, 20, 21)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(12, 18)); + assertCounter(48, 1, 112, 48, 0, 48, 18); + + // Add all at end (for) + robot.interact(() -> { + for (int i = 0; i < 2; i++) table.getColumns().removeLast(); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(10, 16)); + assertCounter(32, 2, 224, 32, 0, 32, 12); + + // Remove before no intersect + robot.interact(() -> Utils.removeAll(table.getColumns(), 0, 1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(8, 14)); + assertCounter(0, 1, 112, 0, 0, 0, 0); + + // Remove before intersect + robot.interact(() -> Utils.removeAll(table.getColumns(), 6, 7, 8)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(5, 11)); + assertCounter(16, 1, 112, 16, 0, 16, 6); + + // Clear and assert EMPTY state + robot.interact(() -> table.getColumns().clear()); + assertState(table, INVALID_RANGE, INVALID_RANGE); + assertCounter(0, 0, 0, 0, 0, 112, 42); + assertEquals(0.0, table.getHPos()); + } + + @Test + void testChangeItemsList(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Change items property + robot.interact(() -> table.setItems(users(50))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 112, 0, 0, 0); + assertRowsCounter(0, 16, 16, 0, 0, 0); + + // Change items property (fewer elements) + robot.interact(() -> table.setItems(users(10))); + assertState(table, IntegerRange.of(0, 9), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 70, 0, 42, 0); + assertRowsCounter(0, 10, 10, 0, 6, 0); + + // Change items property (more elements) + robot.interact(() -> table.setItems(users(50))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 42, 112, 42, 0, 0); + assertRowsCounter(0, 16, 16, 6, 0, 0); + // Unfortunately, we have to update all the rows and cells since the new list contains only new items + // Still we are not creating 6 rows for a total of 42 reused cells, great + + // Scroll(to bottom) and change items property (fewer elements) + robot.interact(() -> { + table.scrollToLastRow(); + resetCounters(); + table.setItems(users(15)); + }); + assertState(table, IntegerRange.of(0, 14), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 105, 0, 7, 0); + assertRowsCounter(0, 15, 15, 0, 1, 0); + + // Fill the viewport, then scroll to max again and set more + robot.interact(() -> { + table.setItems(users(25)); + table.scrollToLastRow(); + resetCounters(); + table.setItems(users(40)); + }); + assertState(table, IntegerRange.of(11, 26), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 112, 0, 0, 0); + assertRowsCounter(0, 16, 16, 0, 0, 0); + + // Change items to empty + robot.interact(() -> table.setItems(null)); + assertState(table, INVALID_RANGE, IntegerRange.of(0, 6)); + assertCounter(0, 0, 0, 0, 0, 112, 42); + assertRowsCounter(0, 0, 0, 0, 16, 6); + } + + @Test + void testItemsPermutation(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Permutation change + robot.interact(() -> FXCollections.sort(table.getItems(), Comparator.comparing(User::id).reversed())); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 112, 0, 0, 0); + assertRowsCounter(0, 16, 16, 0, 0, 0); + } + + @Test + void testSetItems(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Set all (more) + robot.interact(() -> table.getItems().setAll(users(80))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 112, 0, 0, 0); + assertRowsCounter(0, 16, 16, 0, 0, 0); + + // Set all (less) + robot.interact(() -> table.getItems().setAll(users(10))); + assertState(table, IntegerRange.of(0, 9), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 70, 0, 42, 0); + assertRowsCounter(0, 10, 10, 0, 6, 0); + + // Restore old items count, scroll and set all (more) + // Also check re-usability (by identity!!!) + ObservableList tmp = users(50); + robot.interact(() -> { + table.getItems().setAll(tmp.subList(0, 40)); + table.scrollToLastRow(); // Range is [24, 39] + resetCounters(); + table.setItems(tmp); // Range is [26, 41] + }); + assertState(table, IntegerRange.of(26, 41), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 14, 0, 0, 0); + assertRowsCounter(0, 16, 2, 0, 0, 0); + + // Set random items + robot.interact(() -> { + for (int i = 0; i < 5; i++) { + int index = RandomUtils.random.nextInt(26, 42); + table.getItems().set(index, new User()); + } + }); + assertState(table, IntegerRange.of(26, 41), IntegerRange.of(0, 6)); + assertCounter(0, 5, 0, 35, 0, 0, 0); + assertRowsCounter(0, 80, 5, 0, 0, 0); + } + + @Test + void testAddItemsAt0(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Add all at 0 + robot.interact(() -> table.getItems().addAll(0, users(4))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 28, 0, 0, 0); + assertRowsCounter(0, 16, 4, 0, 0, 0); + + // Add at 0 (for) + robot.interact(() -> { + for (int i = 0; i < 3; i++) table.getItems().addFirst(new User()); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 3, 0, 21, 0, 0, 0); + assertRowsCounter(0, 48, 3, 0, 0, 0); + } + + @Test + void testAddItemsAtMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> { + table.setVPos(600.0); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Add before no intersect + robot.interact(() -> table.getItems().addAll(0, users(3))); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 21, 0, 0, 0); + assertRowsCounter(0, 16, 3, 0, 0, 0); + + // Add before intersect + robot.interact(() -> table.getItems().addAll(14, users(4))); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 28, 0, 0, 0); + assertRowsCounter(0, 16, 4, 0, 0, 0); + + // Add after intersect + robot.interact(() -> table.getItems().addAll(29, users(4))); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 21, 0, 0, 0); + assertRowsCounter(0, 16, 3, 0, 0, 0); + + // Add after no intersect + robot.interact(() -> table.getItems().addAll(users(2))); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(0, 16, 0, 0, 0, 0); + } + + @Test + void testAddItemsAtEnd(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> { + table.scrollToLastRow(); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Add only one at end + robot.interact(() -> table.getItems().add(new User())); + assertState(table, IntegerRange.of(35, 50), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 7, 0, 0, 0); + assertRowsCounter(0, 16, 1, 0, 0, 0); + + // Add all at end (for) + robot.interact(() -> { + for (int i = 0; i < 3; i++) table.getItems().add(new User()); + }); + assertState(table, IntegerRange.of(36, 51), IntegerRange.of(0, 6)); + assertCounter(0, 3, 0, 7, 0, 0, 0); + assertRowsCounter(0, 48, 1, 0, 0, 0); + + // Add before no intersect + robot.interact(() -> table.getItems().addAll(0, users(2))); + assertState(table, IntegerRange.of(36, 51), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 14, 0, 0, 0); + assertRowsCounter(0, 16, 2, 0, 0, 0); + + // Add before intersect + robot.interact(() -> table.getItems().addAll(34, users(4))); + assertState(table, IntegerRange.of(36, 51), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 28, 0, 0, 0); + assertRowsCounter(0, 16, 4, 0, 0, 0); + } + + @Test + void testRemoteItemsAt0(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Remove all at 0 + robot.interact(() -> Utils.removeAll(table, 0, 1, 2, 3)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 28, 0, 0, 0); + assertRowsCounter(0, 16, 4, 0, 0, 0); + + // Remove at 0 (for) + robot.interact(() -> { + for (int i = 0; i < 4; i++) table.getItems().removeFirst(); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 4, 0, 28, 0, 0, 0); + assertRowsCounter(0, 64, 4, 0, 0, 0); + + // Remove until cannot fill viewport + robot.interact(() -> Utils.removeAll(table, IntegerRange.of(0, 31))); + assertEquals(10, table.size()); + assertState(table, IntegerRange.of(0, 9), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 70, 0, 42, 0); + assertRowsCounter(0, 10, 10, 0, 6, 0); + } + + @Test + void testRemoveItemsAtMiddle(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> { + table.setVPos(600.0); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Remove before no intersect + robot.interact(() -> Utils.removeAll(table, 0, 1)); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 14, 0, 0, 0); + assertRowsCounter(0, 16, 2, 0, 0, 0); + + // Remove before intersect + robot.interact(() -> Utils.removeAll(table, 14, 15, 16, 17)); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 28, 0, 0, 0); + assertRowsCounter(0, 16, 4, 0, 0, 0); + + // Remove after intersect + robot.interact(() -> Utils.removeAll(table, 31, 32, 33)); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 7, 0, 0, 0); + assertRowsCounter(0, 16, 1, 0, 0, 0); + + // Remove after no intersect + robot.interact(() -> Utils.removeAll(table, 32, 33)); + assertState(table, IntegerRange.of(16, 31), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(0, 16, 0, 0, 0, 0); + + // Remove enough to change vPos and range + robot.interact(() -> Utils.removeAll(table, IntegerRange.of(0, 18))); + assertEquals(20, table.size()); + assertEquals(272, table.getVPos()); + assertState(table, IntegerRange.of(4, 19), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 49, 0, 0, 0); + assertRowsCounter(0, 16, 7, 0, 0, 0); + + // Do it again but this time from bottom + robot.interact(() -> Utils.removeAll(table, 18, 19)); + assertEquals(18, table.size()); + assertEquals(208, table.getVPos()); + assertState(table, IntegerRange.of(2, 17), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 14, 0, 0, 0); + assertRowsCounter(0, 16, 2, 0, 0, 0); + } + + @Test + void testRemoveItemsAtEnd(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> { + table.scrollToLastRow(); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(34, 49), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + // Remove all at end + robot.interact(() -> Utils.removeAll(table, 46, 47, 48, 49)); + assertEquals(1104.0, table.getVPos()); + assertState(table, IntegerRange.of(30, 45), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 28, 0, 0, 0); + assertRowsCounter(0, 16, 4, 0, 0, 0); + + // Remove at end (for) + robot.interact(() -> { + for (int i = 0; i < 4; i++) table.getItems().removeLast(); + }); + assertEquals(976.0, table.getVPos()); + assertState(table, IntegerRange.of(26, 41), IntegerRange.of(0, 6)); + assertCounter(0, 4, 0, 28, 0, 0, 0); + assertRowsCounter(0, 64, 4, 0, 0, 0); + + // Remove before no intersect + robot.interact(() -> Utils.removeAll(table, 0, 1)); + assertEquals(912.0, table.getVPos()); + assertState(table, IntegerRange.of(24, 39), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 0, 0, 0, 0); + assertRowsCounter(0, 16, 0, 0, 0, 0); + + // Remove before intersect + robot.interact(() -> Utils.removeAll(table, 22, 23, 24, 25)); + assertEquals(784.0, table.getVPos()); + assertState(table, IntegerRange.of(20, 35), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 14, 0, 0, 0); + assertRowsCounter(0, 16, 2, 0, 0, 0); + + // Remove enough to cache cells + robot.interact(() -> Utils.removeAll(table, IntegerRange.of(0, 21))); + assertEquals(80.0, table.getVPos()); + assertState(table, IntegerRange.of(0, 13), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 0, 0, 14, 0); + assertRowsCounter(0, 14, 0, 0, 2, 0); + } + + @Test + void testRemoveItemsSparse(FxRobot robot) { + StackPane pane = setupStage(); + Table table = new Table(users(50)); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + + robot.interact(() -> Utils.removeAll(table, 0, 3, 4, 8, 10, 11)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(0, 1, 0, 42, 0, 0, 0); + assertRowsCounter(0, 16, 6, 0, 0, 0); + } + + @Test + void testVariableMode(FxRobot robot) { + // Don't worry about the large layouts numbers, those are 'partial' layouts and are much lighter than full layouts + // Some cells are just resized while others are just moved + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(3); + robot.interact(() -> pane.getChildren().add(table)); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 6)); + assertCounter(112, 1, 112, 112, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + assertLength(table, 50 * 32, 10 * 180); + + // Switch mode + robot.interact(table::switchColumnsLayoutMode); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(48, 1, 48, 48, 0, 0, 0); + assertLength(table, 50 * 32, 10 * 180); + + // Increase width of column to random value + int w = RandomUtils.random.nextInt((int) table.getColumnsSize().getWidth(), 300); + robot.interact(() -> setColumnWidth(table.getColumns().get(6), w)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 0, 0, 0, 0, 0, 0); // 0 layouts because none of the columns from 6 are in the viewport + assertLength(table, 50 * 32, (10 * 180) - 180 + w); + + // Increase width of column (in viewport) to random value + int w2 = RandomUtils.random.nextInt((int) table.getColumnsSize().getWidth(), 200); + robot.interact(() -> setColumnWidth(table.getColumns().get(1), w2)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 32, 0, 0, 0, 0, 0); + assertLength(table, 50 * 32, (10 * 180) - 360 + w + w2); + + // Decrease below minimum + robot.interact(() -> setColumnWidth(table.getColumns().get(6), 100)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 0, 0, 0, 0, 0, 0); // 0 layouts because none of the columns from 6 are in the viewport + assertLength(table, 50 * 32, (10 * 180) - 180 + w2); + + // Decrease below the minimum (in viewport) + robot.interact(() -> setColumnWidth(table.getColumns().get(1), 100)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 32, 0, 0, 0, 0, 0); // 32 layouts because only columns 1 and 2 are in the viewport + assertLength(table, 50 * 32, 10 * 180); + + // Increase table's width and test the last column + robot.interact(() -> { + setWindowPos(table, 0, Double.NaN); + setWindowSize(table, 1920, -1); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 112, 0, 0, 0, 0, 0); + assertEquals(300, table.getColumns().getLast().getWidth()); + // Columns from 3 to 9 are now in the viewport and need to lay out + + // Decrease table's width and test the last column + robot.interact(() -> setWindowSize(table, 1840, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + assertLength(table, 50 * 32, 1840); + assertEquals(220, table.getColumns().getLast().getWidth()); + + // Now increase the last column's width + // Window size is still at 1840 + robot.interact(() -> table.getColumns().getLast().resize(250)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + assertLength(table, 50 * 32, 1870); + assertEquals(250, table.getColumns().getLast().getWidth()); + + // Increase window size + // Column is now at 300 + robot.interact(() -> setWindowSize(table, 1920, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + assertLength(table, 50 * 32, 1920); + assertEquals(300, table.getColumns().getLast().getWidth()); + + // Decrease by a lot + robot.interact(() -> setWindowSize(table, 720, -1)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(0, 16, 0, 0, 0, 0, 0); + assertLength(table, 50 * 32, 1870); + assertEquals(250, table.getColumns().getLast().getWidth()); + // Column is still at 250 because not that is its preferred width value + } + + @Test + void testVariableModeLC(FxRobot robot) { // LC -> List changes + StackPane pane = setupStage(); + Table table = new Table(users(50)) + .addEmptyColumns(3); + robot.interact(() -> { + // Switch mode + table.switchColumnsLayoutMode(); + // Resize every column at 200 + table.getColumns().forEach(c -> c.resize(200)); + // Set window size and pos + setWindowPos(pane, 0, Double.NaN); + setWindowSize(pane, 1920, -1); + pane.getChildren().add(table); + }); + + // Assert init + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 9)); + assertCounter(160, 1, 160, 160, 0, 0, 0); + assertRowsCounter(16, 16, 16, 0, 0, 0); + assertLength(table, 50 * 32, 10 * 200); + + // Remove random column + robot.interact(() -> table.getColumns().remove(RandomUtils.randFromList(table.getColumns()))); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 8)); + assertCounter(0, 1, 144, 0, 0, 16, 6); + assertLength(table, 50 * 32, 1920); // 1920 because the windows' width is 1920px + + // Add columns at random pos, set width to 250 + robot.interact(() -> { + for (int i = 0; i < 2; i++) { + EmptyColumn c = new EmptyColumn("Rand " + i, i); + c.resize(250); + table.getColumns().add(RandomUtils.random.nextInt(0, table.getColumns().size()), c); + } + resetCounters(); // Not relevant + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 10)); + assertLength(table, 50 * 32, (9 * 200) + (2 * 250)); + + // Swap two columns + robot.interact(() -> swapColumns(table, 3, 6)); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 10)); + assertLength(table, 50 * 32, (9 * 200) + (2 * 250)); + + // Swap two columns (last) + double wBefore = table.getColumns().getLast().getWidth(); + robot.interact(() -> { + // Change columns width before + table.setColumnsWidth(150); + for (VFXTableColumn> c : table.getColumns()) { + if (c.getWidth() == 250.0) continue; + c.resize(-1); + } + resetCounters(); // Not relevant + + swapColumns(table, 0, table.getColumns().size() - 1); + }); + assertState(table, IntegerRange.of(0, 15), IntegerRange.of(0, 10)); + assertLength(table, 50 * 32, 1920); + assertTrue(table.getColumns().getFirst().getWidth() < wBefore); + assertTrue(table.getColumns().getLast().getWidth() >= 220); + + // Clear + robot.interact(() -> table.getColumns().clear()); + resetCounters(); // Not relevant + assertState(table, INVALID_RANGE, INVALID_RANGE); + assertLength(table, 0, 0); + } +} diff --git a/src/test/java/jmh/JMHTestSubMap.java b/src/test/java/jmh/JMHTestSubMap.java new file mode 100644 index 0000000..451b9b5 --- /dev/null +++ b/src/test/java/jmh/JMHTestSubMap.java @@ -0,0 +1,84 @@ +package jmh; + + +import org.junit.jupiter.api.Test; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.infra.Blackhole; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; +import org.openjdk.jmh.runner.options.TimeValue; + +import java.util.HashMap; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; + +@State(Scope.Benchmark) +@SuppressWarnings("NewClassNamingConvention") +public class JMHTestSubMap { + private static TestMap map; + + @Test + void runBenchmarks() throws Exception { + Options opt = new OptionsBuilder() + .include(this.getClass().getName() + ".*") + .mode(Mode.Throughput) + .warmupTime(TimeValue.seconds(1)) + .warmupIterations(5) + .threads(1) + .measurementIterations(5) + .forks(1) + .shouldFailOnError(true) + .shouldDoGC(true) + .build(); + new Runner(opt).run(); + } + + @Setup(Level.Invocation) + public void setup() { + map = new TestMap(); + } + + @Benchmark + @OutputTimeUnit(TimeUnit.SECONDS) + public void removeEachBestCase() { + for (int i = 80; i < map.size(); i++) { + map.remove(i); + } + } + + @Benchmark + @OutputTimeUnit(TimeUnit.SECONDS) + public void removeEachWorstCase() { + for (int i = 20; i < map.size(); i++) { + map.remove(i); + } + } + + @Benchmark + @OutputTimeUnit(TimeUnit.SECONDS) + public void subMapBestCase(Blackhole blackhole) { + NavigableMap nMap = new TreeMap<>(map); + Map newMap = new HashMap<>(nMap.subMap(0, 20)); + blackhole.consume(nMap); + } + + @Benchmark + @OutputTimeUnit(TimeUnit.SECONDS) + public void subMapWorstCase(Blackhole blackhole) { + NavigableMap nMap = new TreeMap<>(map); + Map newMap = new HashMap<>(nMap.subMap(0, 80)); + blackhole.consume(nMap); + } + + public static class TestMap extends HashMap { + { + for (int i = 0; i < 100; i++) { + int size = size(); + put(size, size); + } + } + } +} diff --git a/src/test/java/misc/IndexBiMapPOCTests.java b/src/test/java/misc/IndexBiMapPOCTests.java new file mode 100644 index 0000000..b962c2f --- /dev/null +++ b/src/test/java/misc/IndexBiMapPOCTests.java @@ -0,0 +1,46 @@ +package misc; + +import io.github.palexdev.mfxcore.base.beans.range.IntegerRange; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static utils.Utils.items; + +public class IndexBiMapPOCTests { + + @Test + void testIntegersEquals() { + // Prerequisites + List data = items(150, 100); + IntegerRange range = IntegerRange.of(5, 14); + Map> byKey = new HashMap<>(); + + // Setup + for (Integer i : range) { + Integer d = data.get(i); + byKey.computeIfAbsent(d, idx -> new LinkedHashSet<>()).add(i); + } + + // First assertion, maps are synchronized + for (Integer i : range) { + Integer d = data.get(i); + SequencedSet set = byKey.get(d); + assertNotNull(set); + assertFalse(set.isEmpty()); + } + + // Replace data with equal values + data = items(150, 100); + + // Repeat, assertion should still be valid + for (Integer i : range) { + Integer d = data.get(i); + SequencedSet set = byKey.get(d); + assertNotNull(set); + assertFalse(set.isEmpty()); + } + } +} diff --git a/src/test/java/misc/IndexBiMapTests.java b/src/test/java/misc/IndexBiMapTests.java new file mode 100644 index 0000000..fe63857 --- /dev/null +++ b/src/test/java/misc/IndexBiMapTests.java @@ -0,0 +1,67 @@ +package misc; + +import interactive.table.TableTestUtils.User; +import io.github.palexdev.virtualizedfx.utils.IndexBiMap; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class IndexBiMapTests { + + @Test + void testDuplicates1() { + List strings = List.of( + "String 0", + "String 1", + "String 2", + "String 3", + "String 4", + "String 5", + "String 6", + "String 0", + "String 3", + "String 0" + ); + IndexBiMap map = new IndexBiMap<>(); + for (int i = 0; i < strings.size(); i++) { + String s = strings.get(i); + map.put(i, s, Integer.valueOf(s.split(" ")[1])); + } + assertTrue(map.isValid()); + } + + @Test + void testDuplicates2() { + List users = List.of( + new User("A", "A", 0), + new User("B", "B", 1), + new User("A", "A", 0), + new User("B", "B", 1), + new User("C", "C", 2), + new User("B", "B", 9) + ); + IndexBiMap map = new IndexBiMap<>(); + for (int i = 0; i < users.size(); i++) { + User user = users.get(i); + map.put(i, user, user.birthYear()); + } + assertTrue(map.isValid()); + } + + @Test + void testDuplicates3() { + User uA = new User("A", "A", 0); + User uB = new User("B", "B", 1); + User uC = new User("C", "C", 2); + User uB9 = new User("B", "B", 9); + List users = List.of(uA, uB, uA, uB, uC, uB9); + IndexBiMap map = new IndexBiMap<>(); + for (int i = 0; i < users.size(); i++) { + User user = users.get(i); + map.put(i, user, user.birthYear()); + } + assertTrue(map.isValid()); + } +} diff --git a/src/test/java/utils/Utils.java b/src/test/java/utils/Utils.java index ad2bf2f..5e58941 100644 --- a/src/test/java/utils/Utils.java +++ b/src/test/java/utils/Utils.java @@ -33,6 +33,11 @@ public static void debugView(FxRobot robot, Node node) { ScenicView.show(node.getScene()); } + public static void debugView(FxRobot robot, Node node, long sleepMillis) { + debugView(robot, node); + sleep(sleepMillis); + } + public static void sleep(long millis) { try { Thread.sleep(millis); @@ -52,18 +57,39 @@ public static void setWindowSize(Node node, double w, double h) { if (h >= 0) window.setHeight(h); } + public static void setWindowPos(Node node, double pos) { + setWindowPos(node, pos, pos); + } + + public static void setWindowPos(Node node, double x, double y) { + Scene scene = node.getScene(); + if (scene == null) throw new NullPointerException("Node is not in a Scene"); + Window window = scene.getWindow(); + if (window == null) throw new NullPointerException("Scene is not in a Window"); + if (!Double.isNaN(x)) window.setX(x); + if (!Double.isNaN(y)) window.setY(y); + } + public static void removeAll(VFXContainer container, int... indexes) { + removeAll(container.getItems(), indexes); + } + + public static void removeAll(VFXContainer container, IntegerRange range) { + removeAll(container.getItems(), range); + } + + public static void removeAll(ObservableList list, int... indexes) { List rem = Arrays.stream(indexes) - .mapToObj(container.getItems()::get) + .mapToObj(list::get) .toList(); - container.getItems().removeAll(rem); + list.removeAll(rem); } - public static void removeAll(VFXContainer container, IntegerRange range) { + public static void removeAll(ObservableList list, IntegerRange range) { List rem = IntStream.rangeClosed(range.getMin(), range.getMax()) - .mapToObj(container.getItems()::get) + .mapToObj(list::get) .toList(); - container.getItems().removeAll(rem); + list.removeAll(rem); } public static ObservableList items(int cnt) { diff --git a/wiki/Table.md b/wiki/Table.md index 168d621..6195ee6 100644 --- a/wiki/Table.md +++ b/wiki/Table.md @@ -32,7 +32,7 @@ | Property | Description | CSS Property | Type | Default Value | |----------------------|-------------------------------------------------------------------------------------------|---------------------------|-------------------------|---------------| -| 6) cellHeight | Specifies the height of each row (cells implicitly) | -vfx-cell-height | Double | 32.0 | +| 6) rowHeight | Specifies the height of each row (cells implicitly) | -vfx-row-height | Double | 32.0 | | 7) columnSize | Specifies the width and height of each column | -vfx-column-size | Size | [100.0, 32.0] | | 8) columnsLayoutMode | Specifies whether the columns should have fixed sizes | -vfx-columns-layout-mode | Enum(ColumnsLayoutMode) | FIXED | | 9) bufferSize | Specifies the extra number of rows and columns to render (makes scrolling smoother) | -vfx-buffer-size | Enum(BufferSize) | Standard(2) | @@ -177,6 +177,7 @@ with [ T ] are table-related meaning that they could affect the whole system or dispose(); this.cells = map; + this.columnsRange = range; onCellsChanged(); // To update children // Make sure to also call requestViewportLayout() where appropriate to update the layout ``` @@ -227,9 +228,59 @@ with [ T ] are table-related meaning that they could affect the whole system or the table manager's method is also responsible for setting it for columns that are added/removed. At init time, since the listener is added in the skin, the method needs to be called with a `null` parameter, in such occasion, there's no need to update the rows of course. -3) **[ T ]** Same algorithm used for the other containers when the cell factory changed. +3) **[ T ]** ~~Same algorithm used for the other containers when the cell factory changed. Current rows are to be **disposed**, which means that all the **cells** are going to be **cached** by the "parent" column. - When new rows are created the old cells will be reused. No need to recompute positions and sizes. + When new rows are created the old cells will be reused. No need to recompute positions and sizes.~~ + Let's not forget we are dealing with a bi-state component, there's the **viewport state** and then the **rows' state** + (each row has its own state). The first is used to keep track of which and how many rows there are in the viewport. + The second is used by each row to know which and how many items/cells they should contain. + A consequence of such architecture, in this specific case, is that the viewport state becomes invalid, but the state of + each row technically remains valid. Which means that there is no need to cache the cells and thus wasting performance, + it's enough to move them over to the new respective row. Let's see a pseudo-code example: + ```java + // Row factory changes + // As usual we delegate the update to the manager... + VFXTableState state = ...; // Current state + // Make sure we can create a valid new state before the actual computation + + // Ranges do not change, as well as positions + // Be careful though, if the old state is EMPTY then we can't take the ranges from it, but rather we need to ask the VFXTableHelper + IntegerRange rowsRange = (state != VFXTableState.EMPTY) ? state.getRowsRange() : helper.rowsRange(); + IntegerRange columnsRange = (state != VFXTableState.EMPTY) ? state.getColumnsRange() : helper.columnsRange(); + VFXTableState newState = new VFXTableState<>(...); + + // Iterate over the rows range and create the new rows using the new factory + Function> newFn = ...; + for (Integer idx : rowsRange) { + T item = ...; // get item at index idx + VFXTableRow oldRow = state.getRows().get(idx); // Get the old row at the same index + VFXTableRow row = newFn.apply(item); + // Here's where magic happens + row.copyState(oldRow); + // As the name suggests, we are telling the new row to copy the state of the old row, what this means is: + // 1) The new row will have the same index of the old one + // 2) The new row will have the same columns range of the old one + // 3) The new row will use the cells map of the old one, and the latter will have its cells map instance set to null (important for disposal, we don't want to dispose the cells too!) + // 4) Each cell in the map has to be updated by invoking cell.updateRow(this) so that every cell can store the correct row instance + // 5) Add the cells to the row by invoking onCellsChanged() + + // Beware!! + // The above code is a little different in reality. If the old state is EMPTY then we can't get any old row to copy the state from + // Which means that the new row will need a new state by invoking row.updateIndex(idx) and row.updateColumns(...) + + // Finally we can add 'row' to the new state + newState.addRow(idx, item, row); + } + + // The above code should execute only if preliminary checks have passed + // The below code will run always + state.dispose(); // Dispose the old state, the old rows are not needed anymore. Beware, this will !!clear!! and cache the rows + table.getCache().clear(); // This will complete the disposal of the old rows by removing them from the cache and also invoking the dispose() method on each of them + + // The below code runs only if a new state was generated newState != null + newState.setRowsChanged(true); // This will trigger a layout call + table.update(newState); + ``` 4) **[ R ]** When the **vertical** position changes, I believe the same algorithm used for the other containers can be used, it's enough to update the rows with the items that are not in the viewport anymore (which implicitly means updating the cells). When the **horizontal** position changes then we need to iterate over each row and update their columns range. @@ -304,6 +355,52 @@ So, we still would need to get its index by using `indexOf(...)` (**NOT feasible As discussed [above, at point 2](#_on-changeseventsactions_); in case we use a `StateMap`, we could retrieve and manipulate the cells by using the **column as a key**. +### The last column + +In table components, there is a common convention about the last column: _if the container is bigger than all the +columns' width combined, then the last column should take up all the remaining empty space_. +This is quite problematic for the architecture of VFXTable. In other words, the last column is breaking one of the core +rules of virtualization: all cells/subcomponents must have fixed size, so that values such as the max scroll, estimated/virtual +length,... can be computed quickly and without fail. +Not only that, since the virtualized containers react to geometry changes (width/height changes), in terms of layout, +only and only if the range of items to display also changes. This assumption is also broken by the aforementioned convention, +because every time the table's width changes we must ensure not only that the last column is resized properly, but also the +rows and their cells. All of this simply means: _potential performance issues_. +There are indeed ways to mitigate it, let's analyze the situation by checking what needs to be updated and how: + +**Issues** +1) **virtualMaxX:** we may assume the estimated width is simply `columnsNum * columnsWidth` but doing so will lead to an + incorrect value in case the table's width is bigger. If we have 5 columns of 100px width each, and the table is 500px wide, + the estimated width will be **600px** not 500px because the last column must be resized to 200px. +2) **layoutColumns:** to ensure the last columns is sized correctly, every time the table's width changes we must invoke + the `layoutColumns()` method. However, doing so is a waste, because technically we just have to resize the last column. +3) **layoutRows:** as for the rows, there is no other way than resize all the rows in the viewport by calling `layoutRows()`. +4) **layoutCells:** the `layoutRows()` invocation will also lead to the layout of all the cells, and this is also a waste + because the only cells to resize are the last column ones. Hence, we need to perform a partial layout. + +**Solutions** +1) We could modify the computation as follows `Math.max(tableWidth, columnsNum * columnsWidth)`. In **FIXED** layout mode, + there is only one scenario for the last column to be bigger than the fixed size: when the table is bigger than `columnsNum * columnsWidth`. + In other words, if the table's width is greater than the estimated width, than that's our `virtualMaxX`. + +_**Note:** before invoking the layout methods, we must determine where and how to do it. There are two places that can catch +such changes: the manager's `onGeometryChanged()` and the skin's listener. The issue about the first one is that it also +triggers for height changes, so we may end up calling the layout methods when unnecessary. On the other hand the second +place does not have such issue, but does not allow for easy customization on the user-end. +**Ideally we should offer an in-between solution**._ + +2) We could modify the method to accept an index parameter, which basically means we want to lay out the columns starting + from the given index to the end given by the current columns range. If the index is -1 or outside the range, simply + perform a full layout. This is also good to support partial layout for the **VARIABLE** layout mode. + Unfortunately, we can't retrieve the index of a column without relying on the too slow `indexOf()` method, so instead + we have to pass the starting column as parameter, figure out it's index in a loop, and then use the index on the cells too. +3) The only change we could make to the rows would be to bind their width to the table's width. However, it is not very + useful. Since we need to identify such changes, and consequently trigger the layout methods (when needed), there is no + benefit in automatizing it for the rows; after all, their layout method will also handle the cells. + For this reason, we should modify this method too to accept an index parameter which is going to tell us which cells + need to be laid out, in other words, it would allow for a partial layout here too. +4) Same 3, basically pass the index parameter here too and perform a partial layout when a full one is not needed. + ### Variable layout mode Handling layout while in **VARIABLE** mode is definitely a complex situation. There are two ways I can think of: