Skip to content

Commit 2dd44a7

Browse files
committed
Add column view utils
1 parent 2880516 commit 2dd44a7

11 files changed

Lines changed: 1076 additions & 64 deletions

File tree

shellfx-dialogs/src/main/java/com/techsenger/shellfx/dialogs/file/FileChooserDialogFxView.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.techsenger.shellfx.core.dialog.DialogPort;
2222
import com.techsenger.shellfx.core.dialog.DialogResizeEvent;
2323
import com.techsenger.shellfx.core.settings.AppearanceSettings;
24+
import com.techsenger.shellfx.core.window.WindowType;
2425
import com.techsenger.shellfx.dialogs.alert.AlertDialogFxView;
2526
import com.techsenger.shellfx.dialogs.alert.AlertDialogParams;
2627
import com.techsenger.shellfx.dialogs.alert.AlertDialogPresenter;
@@ -61,6 +62,8 @@
6162
import javafx.scene.control.ToggleButton;
6263
import javafx.scene.control.ToggleGroup;
6364
import javafx.scene.control.Tooltip;
65+
import javafx.scene.input.KeyCode;
66+
import javafx.scene.input.KeyEvent;
6467
import javafx.scene.input.MouseEvent;
6568
import javafx.scene.layout.ColumnConstraints;
6669
import javafx.scene.layout.GridPane;
@@ -111,7 +114,13 @@ public class Composer extends AbstractDialogFxView<P>.Composer implements FileCh
111114
public DialogPort addAlertDialog(AlertDialogParams params, String message) {
112115
var dialog = createAlertDialog(params);
113116
dialog.getPresenter().setMessage(message);
114-
getContainer().getComposer().addDialog(dialog);
117+
if (dialog.getPresenter().getWindowType() == WindowType.NESTED) {
118+
getContainer().getComposer().addDialog(dialog);
119+
} else {
120+
dialog.getStage().initOwner(getNode().getScene().getWindow());
121+
dialog.getStage().show();
122+
}
123+
dialog.requestFocus();
115124
return dialog.getPresenter();
116125
}
117126

@@ -540,6 +549,28 @@ protected void addHandlers() {
540549
this.createButton.setOnAction(e -> presenter.onNewDirectory());
541550
this.listButton.setOnAction(e -> getPresenter().onList());
542551
this.detailsButton.setOnAction(e -> getPresenter().onDetails());
552+
// Bubbling handlers (not filters): an in-progress rename's TextField already consumes ENTER/ESCAPE
553+
// itself (see TextFieldColumnListCell), so these never see the key in that case. Only consumed here
554+
// when the selected entry is a directory, so ENTER on a file still reaches the dialog's default (OK)
555+
// button and confirms the selection as before.
556+
this.fileListView.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
557+
if (e.getCode() == KeyCode.ENTER) {
558+
var file = this.fileListView.getSelectionModel().getSelectedItem();
559+
if (file != null && file.isDirectory()) {
560+
e.consume();
561+
presenter.onNavigateDown(file);
562+
}
563+
}
564+
});
565+
this.fileTableView.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
566+
if (e.getCode() == KeyCode.ENTER) {
567+
var file = this.fileTableView.getSelectionModel().getSelectedItem();
568+
if (file != null && file.isDirectory()) {
569+
e.consume();
570+
presenter.onNavigateDown(file);
571+
}
572+
}
573+
});
543574
//when setOnShowing is used then popup height is calculated incorrectly
544575
//maybe because OnMousePressed handler is called before OnShowing handler.
545576
//another reason - update location property only after locations have been populated

shellfx-layout/src/test/java/com/techsenger/shellfx/layout/dockhost/DockSplitPaneTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class DockSplitPaneTest {
6969
static void initJavaFxToolkit() {
7070
try {
7171
System.setProperty("glass.platform", "Headless");
72+
System.setProperty("prism.order", "sw");
7273
Platform.startup(() -> { });
7374
} catch (IllegalStateException alreadyStarted) {
7475
// toolkit already running in this JVM (e.g. started by another test class); nothing to do

shellfx-material/src/main/java/com/techsenger/shellfx/material/list/ColumnListCell.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ public ColumnListCell() {
4646
listView.edit(getIndex());
4747
}
4848
} else {
49-
listView.setSelectedByAction(true);
5049
listView.getSelectionModel().select(getIndex());
5150
NodeUtils.requestFocus(this);
5251
}

shellfx-material/src/main/java/com/techsenger/shellfx/material/list/ColumnListView.java

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,6 @@ private static class ColumnListViewColumn<T> extends IndexedCell<Integer> {
109109
node.getStyleClass().add("column");
110110
node.setAlignment(Pos.TOP_LEFT);
111111
setGraphic(node);
112-
listView.getSelectionModel().selectedIndexProperty().addListener((ov, oldV, newV) -> {
113-
clearSelection();
114-
if (newV.intValue() != -1) {
115-
//scroll to selected to create it if it hasn't beed created yet
116-
var columnIndex = listView.resolveColumnIndex(newV.intValue());
117-
if (getIndex() == columnIndex) {
118-
var rowIndex = listView.resolveRowIndex(newV.intValue());
119-
if (rowIndex < cachedCells.size()) {
120-
setSelectedCell(cachedCells.get(rowIndex));
121-
122-
}
123-
}
124-
}
125-
});
126112
}
127113

128114
@Override
@@ -133,6 +119,14 @@ public void updateItem(Integer item, boolean empty) {
133119
}
134120
super.updateItem(item, empty);
135121
node.pseudoClassStateChanged(EMPTY, empty);
122+
// A cell about to be discarded here (e.g. a column recycled for a different offset after items
123+
// shrank) can currently own scene focus. Removing a focused node from the scene graph does not
124+
// reassign focus - Scene.getFocusOwner() is left pointing at a now-detached node, so no further
125+
// key events (e.g. arrow-key navigation) are dispatched anywhere until something explicitly
126+
// requests focus again. Move focus to the still-live container first.
127+
if (node.isFocusWithin()) {
128+
listView.requestFocus();
129+
}
136130
node.getChildren().clear();
137131
clearSelection();
138132
if (item != null) {
@@ -192,8 +186,16 @@ private void updateCells() {
192186
} else if (item == (this.listView.getColumnCount() - 1) * this.listView.getRowCount()) {
193187
node.getStyleClass().add("last");
194188
}
195-
int endIndex = Math.min(item + this.listView.getRowCount(), this.listView.getItems().size());
196-
var cellItems = this.listView.getItems().subList(item, endIndex);
189+
var items = this.listView.getItems();
190+
// item (this column's own offset) can be momentarily stale relative to a just-shrunk items list:
191+
// refresh()'s reentrancy guard can postpone an ITEMS-triggered offsets rebuild behind another,
192+
// unrelated trigger (see RefreshTrigger), and if that other trigger sees no rowCount change, its
193+
// own updateOffsets() call is skipped too - leaving this column pointing past the end of the new
194+
// list until the next refresh corrects it. Clamp instead of crashing; an empty column here
195+
// self-heals on that next refresh.
196+
var startIndex = Math.min(item, items.size());
197+
int endIndex = Math.min(startIndex + this.listView.getRowCount(), items.size());
198+
var cellItems = items.subList(startIndex, endIndex);
197199
var absentCells = cellItems.size() - this.cachedCells.size();
198200
for (var i = 0; i < absentCells; i++) {
199201
createCell();
@@ -384,8 +386,6 @@ private enum RefreshType {
384386

385387
private int firstVisibleCellIndex = 0;
386388

387-
private boolean selectedByAction;
388-
389389
/**
390390
* Always only one sell can be in edit mode.
391391
*/
@@ -436,6 +436,12 @@ public ColumnListView() {
436436
//firstVisibleCellIndex is set via onResizeStarted.
437437
this.virtualFlow.heightProperty()
438438
.addListener((ov, oldV, newV) -> savePositionAndRefreshView(RefreshTrigger.VIRTUAL_FLOW_HEIGHT));
439+
// A single listener here, instead of one per column (as before): a per-column listener on this
440+
// long-lived view's own selectedIndexProperty would leak every column ever created for the lifetime
441+
// of the view - the property's listener list holds a strong reference to each column, so none of them
442+
// could ever be garbage collected even after being discarded/replaced by the virtual flow.
443+
getSelectionModel().selectedIndexProperty()
444+
.addListener((ov, oldV, newV) -> updateSelectedCellHighlight(newV.intValue()));
439445
virtualFlow.setCellFactory(vf -> new ColumnListViewColumn<>(this) {
440446

441447
{
@@ -469,15 +475,6 @@ public void updateIndex(int index) {
469475
}
470476
});
471477

472-
this.selectionModel.selectedIndexProperty().addListener((ov, oldV, newV) -> {
473-
//there can be two types of events - selection from code or selection from user; user selections are ignored
474-
if (newV.intValue() != -1 && !selectedByAction) {
475-
//scroll to selected to create it if it hasn't beed created yet
476-
var columnIndex = resolveColumnIndex(newV.intValue());
477-
scrollToFirstColumn(columnIndex);
478-
this.selectedByAction = false;
479-
}
480-
});
481478
this.contextMenu.addListener((ov, oldV, newV) -> {
482479
if (newV == null) {
483480
setOnContextMenuRequested(null);
@@ -733,10 +730,6 @@ void scrollToSelected() {
733730

734731
}
735732

736-
void setSelectedByAction(boolean selectedByAction) {
737-
this.selectedByAction = selectedByAction;
738-
}
739-
740733
private ColumnListCell<T> getCell(int columnIndex, int rowIndex) {
741734
ColumnListViewColumn column = this.virtualFlow.getCell(columnIndex);
742735
var cell = (ColumnListCell<T>) column.getNode().getChildren().get(rowIndex);
@@ -827,6 +820,24 @@ private void scrollToCell(int cellIndex) {
827820
scrollToFirstColumn(columnIndex);
828821
}
829822

823+
/**
824+
* Clears the previously selected cell's highlight on every currently realized column and, if
825+
* {@code selectedIndex} resolves to one of them, sets its new selected cell. Off-screen columns are not
826+
* touched here - {@link ColumnListViewColumn#updateItem} already recomputes the correct selected cell from
827+
* scratch whenever such a column is next reused, so there is nothing stale left for them to show once
828+
* scrolled back into view.
829+
*/
830+
private void updateSelectedCellHighlight(int selectedIndex) {
831+
var columnIndex = selectedIndex == -1 ? -1 : resolveColumnIndex(selectedIndex);
832+
var rowIndex = selectedIndex == -1 ? -1 : resolveRowIndex(selectedIndex);
833+
for (var column : this.virtualFlow.getCells()) {
834+
column.clearSelection();
835+
if (column.getIndex() == columnIndex && rowIndex < column.cachedCells.size()) {
836+
column.setSelectedCell(column.cachedCells.get(rowIndex));
837+
}
838+
}
839+
}
840+
830841
/**
831842
* Re-applies {@link #columnWidth} to every currently live column cell. Deliberately independent of
832843
* {@link #refresh(RefreshTrigger, RefreshType)} &mdash; a column width change is a pure view-level resize,

shellfx-material/src/main/java/com/techsenger/shellfx/material/list/ColumnTileView.java

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)