Skip to content

Commit

Permalink
Synchronize selected extension filter (#49)
Browse files Browse the repository at this point in the history
* Synchronize selected extension filter for the native file choosers

* Try to mimic the native behaviour for the WebFileSavePicker

* Refactor extension filter change listeners for both native and web platforms

* Update the TextEditor sample to accommodate the last changes

* Add javadoc documentation for the newly added methods

* Update changelog
  • Loading branch information
besidev authored Nov 19, 2024
1 parent 4b4fef4 commit 854149b
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 161 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
### 0.4.5 (TBD)

#### Improvements
* Synchronize selected extension filter for the native file choosers in the `jpro-file` module.

----------------------

### 0.4.4 (November 8, 2024)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ public class TextEditorSample extends Application {
private static final Logger LOGGER = LoggerFactory.getLogger(TextEditorSample.class);

private static final PseudoClass FILES_DRAG_OVER_PSEUDO_CLASS = PseudoClass.getPseudoClass("files-drag-over");
private static final ExtensionFilter TEXT_EXTENSION_FILTER = ExtensionFilter.of("Text files", ".txt", ".srt", ".md", ".csv");
private static final ExtensionFilter SUBTITLE_EXTENSION_FILTER = ExtensionFilter.of("Subtitle files", ".srt");
private static final ExtensionFilter MARKDOWN_EXTENSION_FILTER = ExtensionFilter.of("Markdown files", ".md");
private static final ExtensionFilter CSV_EXTENSION_FILTER = ExtensionFilter.of("CSV files", ".csv");
private final ObjectProperty<File> lastOpenedFile = new SimpleObjectProperty<>(this, "lastOpenedFile");

@Override
Expand All @@ -77,15 +79,15 @@ public void start(Stage stage) {
}

public Parent createRoot(Stage stage) {
Label dropLabel = new Label("Drop " + TEXT_EXTENSION_FILTER.description().toLowerCase() + " here!");
Label dropLabel = new Label("Drop " + SUBTITLE_EXTENSION_FILTER.description().toLowerCase() + " here!");
StackPane dropPane = new StackPane(dropLabel);
dropPane.getStyleClass().add("drop-pane");

TextArea textArea = new TextArea();
StackPane contentPane = new StackPane(textArea, dropPane);

FileDropper fileDropper = FileDropper.create(contentPane);
fileDropper.setExtensionFilter(TEXT_EXTENSION_FILTER);
fileDropper.setExtensionFilter(SUBTITLE_EXTENSION_FILTER);
fileDropper.setOnDragEntered(event -> {
dropPane.pseudoClassStateChanged(FILES_DRAG_OVER_PSEUDO_CLASS, true);
contentPane.getChildren().setAll(textArea, dropPane);
Expand All @@ -106,7 +108,8 @@ public Parent createRoot(Stage stage) {

Button openButton = new Button("Open", new FontIcon(Material2AL.FOLDER_OPEN));
FileOpenPicker fileOpenPicker = FileOpenPicker.create(openButton);
fileOpenPicker.setSelectedExtensionFilter(TEXT_EXTENSION_FILTER);
fileOpenPicker.getExtensionFilters().addAll(SUBTITLE_EXTENSION_FILTER,
MARKDOWN_EXTENSION_FILTER, CSV_EXTENSION_FILTER);
fileOpenPicker.setOnFilesSelected(fileSources -> {
openFile(fileSources, textArea);
contentPane.getChildren().setAll(textArea);
Expand Down Expand Up @@ -134,7 +137,8 @@ public Parent createRoot(Stage stage) {
fileSavePicker.initialFileNameProperty().bind(lastOpenedFile.map(file ->
FilenameUtils.getName(file.getName())).orElse("subtitle"));
fileSavePicker.initialDirectoryProperty().bind(lastOpenedFile.map(File::getParentFile));
fileSavePicker.setSelectedExtensionFilter(TEXT_EXTENSION_FILTER);
fileSavePicker.getExtensionFilters().addAll(SUBTITLE_EXTENSION_FILTER,
MARKDOWN_EXTENSION_FILTER, CSV_EXTENSION_FILTER);
fileSavePicker.setOnFileSelected(file -> saveToFile(textArea).apply(file));

BorderPane rootPane = new BorderPane(contentPane);
Expand Down
48 changes: 37 additions & 11 deletions jpro-file/src/main/java/one/jpro/platform/file/ExtensionFilter.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package one.jpro.platform.file;

import javafx.stage.FileChooser;

import java.util.List;

/**
Expand All @@ -10,7 +12,7 @@
* format.
*
* @param description the textual description for the filter
* @param extensions a list of the accepted file name extensions
* @param extensions a list of the accepted file name extensions
* @author Besmir Beqiri
*/
public record ExtensionFilter(String description, List<String> extensions) {
Expand All @@ -20,7 +22,7 @@ public record ExtensionFilter(String description, List<String> extensions) {
/**
* Compact constructor for {@code ExtensionFilter}.
*
* @throws NullPointerException if the description or the extensions are {@code null}
* @throws NullPointerException if the description or the extensions are {@code null}
* @throws IllegalArgumentException if the description or the extensions are empty
*/
public ExtensionFilter {
Expand All @@ -32,7 +34,6 @@ public record ExtensionFilter(String description, List<String> extensions) {
*
* @param description the description of the filter
* @param extension the extension to filter
*
* @throws NullPointerException if the description or the extension are {@code null}
* @throws IllegalArgumentException if the description or the extension are empty
*/
Expand All @@ -47,10 +48,10 @@ public ExtensionFilter(String description, String extension) {
* File name extension should be specified in the {@code *.<extension>} format.
*
* @param description the textual description for the filter
* @param extensions an array of the accepted file name extensions
* @throws NullPointerException if the description or the extensions are {@code null}
* @throws IllegalArgumentException if the description or the extensions are empty
* @param extensions an array of the accepted file name extensions
* @return the created {@code ExtensionFilter}
* @throws NullPointerException if the description or the extensions are {@code null}
* @throws IllegalArgumentException if the description or the extensions are empty
*/
public static ExtensionFilter of(String description, String... extensions) {
return new ExtensionFilter(description, List.of(extensions));
Expand All @@ -63,21 +64,46 @@ public static ExtensionFilter of(String description, String... extensions) {
* File name extension should be specified in the {@code *.<extension>} format.
*
* @param description the textual description for the filter
* @param extension the accepted file name extension
* @throws NullPointerException if the description or the extension is {@code null}
* @throws IllegalArgumentException if the description or the extension is empty
* @param extension the accepted file name extension
* @return the created {@code ExtensionFilter}
* @throws NullPointerException if the description or the extension is {@code null}
* @throws IllegalArgumentException if the description or the extension is empty
*/
public static ExtensionFilter of(String description, String extension) {
return new ExtensionFilter(description, extension);
}

/**
* Converts this {@code ExtensionFilter} to a JavaFX {@link FileChooser.ExtensionFilter}.
*
* @return a corresponding {@link FileChooser.ExtensionFilter} instance
*/
public static FileChooser.ExtensionFilter toJavaFXExtensionFilter(ExtensionFilter extensionFilter) {
if (extensionFilter == null) return null;
return new FileChooser.ExtensionFilter(extensionFilter.description(),
extensionFilter.extensions().stream().map(ext -> "*" + ext).toList());
}

/**
* Converts a JavaFX {@link FileChooser.ExtensionFilter} to an {@code ExtensionFilter}.
*
* @param extensionFilter the JavaFX {@link FileChooser.ExtensionFilter} to convert
* @return the corresponding {@code ExtensionFilter} instance
*/
public static ExtensionFilter fromJavaFXExtensionFilter(FileChooser.ExtensionFilter extensionFilter) {
if (extensionFilter == null) return null;
return new ExtensionFilter(extensionFilter.getDescription(),
extensionFilter.getExtensions().stream()
.filter(ext -> ext.startsWith("*"))
.map(ext -> ext.substring(1)).toList());
}

/**
* Validates the arguments.
*
* @param description the textual description for the filter
* @param extensions the accepted file name extensions
* @throws NullPointerException if the description or the extensions are {@code null}
* @param extensions the accepted file name extensions
* @throws NullPointerException if the description or the extensions are {@code null}
* @throws IllegalArgumentException if the description or the extensions are empty
*/
private static void validateArgs(final String description, final List<String> extensions) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package one.jpro.platform.file.picker;

import com.jpro.webapi.WebAPI;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.stage.FileChooser;
import one.jpro.platform.file.ExtensionFilter;
import org.jetbrains.annotations.NotNull;

import java.util.Objects;

import static one.jpro.platform.file.ExtensionFilter.toJavaFXExtensionFilter;

/**
* Base file picker implementation.
*
Expand All @@ -18,6 +27,10 @@ abstract class BaseFilePicker implements FilePicker {

private final Node node;

// Flags to prevent infinite synchronization loops
private boolean updatingFromFileChooser = false;
private boolean updatingFromProperty = false;

/**
* Constructs a new instance with the specified Node.
*
Expand Down Expand Up @@ -64,4 +77,159 @@ public final ExtensionFilter getSelectedExtensionFilter() {
public final void setSelectedExtensionFilter(final ExtensionFilter filter) {
selectedExtensionFilterProperty().setValue(filter);
}

@Override
public final ObjectProperty<ExtensionFilter> selectedExtensionFilterProperty() {
if (selectedExtensionFilter == null) {
selectedExtensionFilter = new SimpleObjectProperty<>(this, "selectedExtensionFilter");
}
return selectedExtensionFilter;
}

/**
* Finds and returns the currently selected {@link ExtensionFilter}. If no filter is selected
* or the selected filter is not present in the list, the first filter in the list is returned.
* If the list is empty, {@code null} is returned.
*
* @return the selected extension filter or a default filter, or {@code null} if no filters are available
*/
final ExtensionFilter findSelectedFilter() {
ExtensionFilter selectedFilter = getSelectedExtensionFilter();
if (selectedFilter == null || !extensionFilters.contains(selectedFilter)) {
return extensionFilters.isEmpty() ? null : extensionFilters.get(0);
} else {
return selectedFilter;
}
}

/**
* Synchronizes the selected {@link ExtensionFilter} between this file picker and the native {@link FileChooser}.
* This ensures that changes in one are reflected in the other without causing infinite update loops.
*
* @param fileChooser the native file chooser to synchronize with; must not be {@code null}
*/
final void synchronizeSelectedExtensionFilter(FileChooser fileChooser) {
fileChooser.selectedExtensionFilterProperty()
.addListener(new WeakChangeListener<>(getNativeSelectedExtensionFilterChangeListener()));
selectedExtensionFilterProperty()
.addListener(new WeakChangeListener<>(getSelectedExtensionFilterChangeListener(fileChooser)));
}

/**
* Creates a {@link ChangeListener} that listens for changes in the native {@link FileChooser}'s
* selected extension filter and updates the corresponding property in this file picker.
*
* @return a change listener for the native file chooser's selected extension filter
*/
@NotNull
private ChangeListener<FileChooser.ExtensionFilter> getNativeSelectedExtensionFilterChangeListener() {
return (observable, oldFilter, newFilter) -> {
if (updatingFromProperty) {
return;
}
updatingFromFileChooser = true;
try {
ExtensionFilter extensionFilter = null;
if (newFilter != null) {
for (ExtensionFilter ef : extensionFilters) {
if (newFilter.getDescription().equals(ef.description())) {
extensionFilter = ef;
break;
}
}
}
setSelectedExtensionFilter(extensionFilter);
} finally {
updatingFromFileChooser = false;
}
};
}

/**
* Creates a {@link ChangeListener} that listens for changes in the native {@link FileChooser}'s
* selected extension filter and updates the corresponding property in this file picker.
*
* @return a change listener for the native file chooser's selected extension filter
*/
@NotNull
private ChangeListener<ExtensionFilter> getSelectedExtensionFilterChangeListener(FileChooser fileChooser) {
return (observable, oldFilter, newFilter) -> {
if (updatingFromFileChooser) {
return;
}

updatingFromProperty = true;
try {
FileChooser.ExtensionFilter extensionFilter = null;
if (newFilter != null) {
for (FileChooser.ExtensionFilter ef : fileChooser.getExtensionFilters()) {
if (newFilter.description().equals(ef.getDescription())) {
extensionFilter = ef;
break;
}
}
}
fileChooser.setSelectedExtensionFilter(extensionFilter);
} finally {
updatingFromProperty = false;
}
};
}

/**
* Creates a {@link ListChangeListener} that listens for changes in the list of {@link ExtensionFilter}
* instances and updates the native {@link FileChooser}'s extension filters accordingly.
* <p>
* This listener handles both additions and removals of extension filters.
* </p>
*
* @param fileChooser the native file chooser whose extension filters will be updated; must not be {@code null}
* @return a list change listener for updating the native file chooser's extension filters
*/
@NotNull
final ListChangeListener<ExtensionFilter> getNativeExtensionFilterListChangeListener(FileChooser fileChooser) {
return change -> {
while (change.next()) {
if (change.wasAdded()) {
for (ExtensionFilter extensionFilter : change.getAddedSubList()) {
fileChooser.getExtensionFilters().add(toJavaFXExtensionFilter(extensionFilter));
}
} else if (change.wasRemoved()) {
for (ExtensionFilter extensionFilter : change.getRemoved()) {
fileChooser.getExtensionFilters().removeIf(filter ->
filter.getDescription().equals(extensionFilter.description()));
}
}
}
};
}

/**
* Creates a {@link ListChangeListener} that listens for changes in the list of {@link ExtensionFilter}
* instances and updates the web-based file uploader's supported extensions accordingly.
* <p>
* This listener handles both additions and removals of extension filters.
* </p>
*
* @param multiFileUploader the web file uploader whose supported extensions will be updated; must not be {@code null}
* @return a list change listener for updating the web file uploader's supported extensions
*/
@NotNull
final ListChangeListener<ExtensionFilter> getWebExtensionFilterListChangeListener(WebAPI.MultiFileUploader multiFileUploader) {
return change -> {
while (change.next()) {
if (change.wasAdded()) {
for (ExtensionFilter extensionFilter : change.getAddedSubList()) {
extensionFilter.extensions()
.forEach(multiFileUploader.supportedExtensions()::add);
}
} else if (change.wasRemoved()) {
for (ExtensionFilter extensionFilter : change.getRemoved()) {
extensionFilter.extensions()
.forEach(multiFileUploader.supportedExtensions()::remove);
}
}
}
};
}
}
Loading

0 comments on commit 854149b

Please sign in to comment.