@@ -111,19 +111,6 @@ private static class ColumnTileViewRow<T> extends IndexedCell<Integer> {
111111 node .getStyleClass ().add ("row" );
112112 node .setAlignment (Pos .CENTER_LEFT );
113113 setGraphic (node );
114- tileView .getSelectionModel ().selectedIndexProperty ().addListener ((ov , oldV , newV ) -> {
115- clearSelection ();
116- if (newV .intValue () != -1 ) {
117- //scroll to selected to create it if it hasn't beed created yet
118- var rowIndex = tileView .resolveRowIndex (newV .intValue ());
119- if (getIndex () == rowIndex ) {
120- var columnIndex = tileView .resolveColumnIndex (newV .intValue ());
121- if (columnIndex < cachedCells .size ()) {
122- setSelectedCell (cachedCells .get (columnIndex ));
123- }
124- }
125- }
126- });
127114 }
128115
129116 @ Override
@@ -135,6 +122,14 @@ public void updateItem(Integer item, boolean empty) {
135122 }
136123 super .updateItem (item , empty );
137124 node .pseudoClassStateChanged (EMPTY , empty );
125+ // A cell about to be discarded here (e.g. a row recycled for a different offset after items
126+ // shrank) can currently own scene focus. Removing a focused node from the scene graph does not
127+ // reassign focus - Scene.getFocusOwner() is left pointing at a now-detached node, so no further
128+ // key events (e.g. arrow-key navigation) are dispatched anywhere until something explicitly
129+ // requests focus again. Move focus to the still-live container first.
130+ if (node .isFocusWithin ()) {
131+ tileView .requestFocus ();
132+ }
138133 node .getChildren ().clear ();
139134 clearSelection ();
140135 if (item != null ) {
@@ -202,8 +197,16 @@ private void updateCells() {
202197 } else if (item == (this .tileView .getRowCount () - 1 ) * this .tileView .getColumnCount ()) {
203198 node .getStyleClass ().add ("last" );
204199 }
205- int endIndex = Math .min (item + this .tileView .getColumnCount (), this .tileView .getItems ().size ());
206- var cellItems = this .tileView .getItems ().subList (item , endIndex );
200+ var items = this .tileView .getItems ();
201+ // item (this row's own offset) can be momentarily stale relative to a just-shrunk items list:
202+ // refresh()'s reentrancy guard can postpone an ITEMS-triggered offsets rebuild behind another,
203+ // unrelated trigger (see RefreshTrigger), and if that other trigger sees no rowCount change, its
204+ // own updateOffsets() call is skipped too - leaving this row pointing past the end of the new
205+ // list until the next refresh corrects it. Clamp instead of crashing; an empty row here
206+ // self-heals on that next refresh.
207+ var startIndex = Math .min (item , items .size ());
208+ int endIndex = Math .min (startIndex + this .tileView .getColumnCount (), items .size ());
209+ var cellItems = items .subList (startIndex , endIndex );
207210 var absentCells = cellItems .size () - this .cachedCells .size ();
208211 for (var i = 0 ; i < absentCells ; i ++) {
209212 createCell ();
@@ -392,8 +395,6 @@ private enum RefreshType {
392395
393396 private int firstVisibleCellIndex = 0 ;
394397
395- private boolean selectedByAction ;
396-
397398 /**
398399 * Always only one cell can be in edit mode.
399400 */
@@ -441,6 +442,12 @@ public ColumnTileView() {
441442 savePositionAndRefreshView (RefreshTrigger .VIRTUAL_FLOW_WIDTH );
442443 updateCellWidths ();
443444 });
445+ // A single listener here, instead of one per row (as before): a per-row listener on this long-lived
446+ // view's own selectedIndexProperty would leak every row ever created for the lifetime of the view -
447+ // the property's listener list holds a strong reference to each row, so none of them could ever be
448+ // garbage collected even after being discarded/replaced by the virtual flow.
449+ getSelectionModel ().selectedIndexProperty ()
450+ .addListener ((ov , oldV , newV ) -> updateSelectedCellHighlight (newV .intValue ()));
444451 virtualFlow .setCellFactory (vf -> new ColumnTileViewRow <>(this ) {
445452
446453 {
@@ -474,15 +481,6 @@ public void updateIndex(int index) {
474481 }
475482 });
476483
477- this .selectionModel .selectedIndexProperty ().addListener ((ov , oldV , newV ) -> {
478- //there can be two types of events - selection from code or selection from user; user selections are ignored
479- if (newV .intValue () != -1 && !selectedByAction ) {
480- //scroll to selected to create it if it hasn't beed created yet
481- var rowIndex = resolveRowIndex (newV .intValue ());
482- scrollToFirstRow (rowIndex );
483- this .selectedByAction = false ;
484- }
485- });
486484 this .contextMenu .addListener ((ov , oldV , newV ) -> {
487485 if (newV == null ) {
488486 setOnContextMenuRequested (null );
@@ -695,6 +693,10 @@ protected void layoutChildren() {
695693 double width = getWidth ();
696694 double height = getHeight ();
697695 virtualFlow .resizeRelocate (0 , 0 , width , height );
696+ // resizeRelocate() only triggers the flow's own layout when its size actually changes, so a scroll
697+ // (a position/state change with no size change, e.g. from VirtualFlowUtils#scrollTo) would otherwise
698+ // never get processed here at all - explicitly laying it out unconditionally covers that case too.
699+ virtualFlow .layout ();
698700 }
699701
700702 void setEditingCellIndex (int editingCellIndex ) {
@@ -715,10 +717,6 @@ void scrollToSelected() {
715717 }
716718 }
717719
718- void setSelectedByAction (boolean selectedByAction ) {
719- this .selectedByAction = selectedByAction ;
720- }
721-
722720 /**
723721 * Returns the width each cell should have to make {@link #getColumnCount()} of them exactly fill the
724722 * available viewport width, or a negative value if not resolvable yet (no columns configured).
@@ -817,6 +815,24 @@ private void updateCellWidths() {
817815 }
818816 }
819817
818+ /**
819+ * Clears the previously selected cell's highlight on every currently realized row and, if
820+ * {@code selectedIndex} resolves to one of them, sets its new selected cell. Off-screen rows are not
821+ * touched here - {@link ColumnTileViewRow#updateItem} already recomputes the correct selected cell from
822+ * scratch whenever such a row is next reused, so there is nothing stale left for them to show once
823+ * scrolled back into view.
824+ */
825+ private void updateSelectedCellHighlight (int selectedIndex ) {
826+ var rowIndex = selectedIndex == -1 ? -1 : resolveRowIndex (selectedIndex );
827+ var columnIndex = selectedIndex == -1 ? -1 : resolveColumnIndex (selectedIndex );
828+ for (var row : this .virtualFlow .getCells ()) {
829+ row .clearSelection ();
830+ if (row .getIndex () == rowIndex && columnIndex < row .cachedCells .size ()) {
831+ row .setSelectedCell (row .cachedCells .get (columnIndex ));
832+ }
833+ }
834+ }
835+
820836 /**
821837 * This method is called when view or data has been changed.
822838 *
0 commit comments