Featured Post

Applying Email Validation to a JavaFX TextField Using Binding

This example uses the same controller as in a previous post but adds a use case to support email validation.  A Commons Validator object is ...

Saturday, August 22, 2015

Implementing a Windows Explorer-like Rename in JavaFX

This blog post shows how to implement a rename feature on a list-based browser in JavaFX.  A JavaFX ListView presents a list of items that drive a detailed form and subform when selected.  The rename feature lets you rename the item in the sorted list which is then saved to a datastore.

This video demonstrates the function.  A Profile is a saved collection of values entered into the form on the right and the subform launched from the Configure button (not shown).  F2 or a context menu selection will initiate the rename.



GitHub

For the full source code in this example, clone https://github.com/bekwam/resignator-repos-1.git.  This project is under development, but the executable should work.  Email dev-at-bekwam.com if you have an trouble.

Scene Builder

The user interface for the app is built using Scene Builder 2.0.  The rename feature focuses on the ListView in the left third of the screen.

Main Screen of App with Browser on Left
The hierarchy of the components is a ListView wrapped in an AnchorPane.  The ListView is put in a VBox along with a custom header "Profile Browser" which is an HBox and Label with TitledPane-like styling.  The ListView contains a pair of ContextMenu MenuItems including one for the rename function.

ListView Contains ContextMenu Items
The properties window has the ListView set to editable.  This will enable a double-click to put the component's selection into edit mode.  I'll also register a F2 key event handler and a right-click context menu.  So, there are 3 ways to initiate the rename feature.

Editable ListView Setting
The remaining Scene Builder definition is on the Code page.  lvProfiles is the name of the ListView and I've put a handler on the On Edit Commit event.  Although there are multiple ways to call the rename, nothing happens until the name is committed by pressing the return key in edit mode (more on edit mode later).

On Edit Commit ListView Setting
There is also a handler on the key event in the ListView.

Handler on Key Event in ListView
This is an action handler put on the Rename ContextMenu MenuItem (not the ListView).

Handler on ContextMenu Event in ListView

Definition

The backing store of the ListView is a list of Strings.  Here is the definition of the ListView from the JavaFX Controller class.

@FXML
ListView<String> lvProfiles;

Editor

I rely heavily on the off-the-shelf behavior I get from Scene Builder.  However, the following customization is needed to tell the ListView which editor to use when the control is put in edit mode.  A user interaction -- the F2 key, context-menu, double-click -- will put the control in edit mode and with the following line of code in an @FXML initialize() method display a TextField.

lvProfiles.setCellFactory(TextFieldListCell.forListView());

Invoking

This is the rename() method which is called from the right-click ContextMenu.  It puts the ListView into edit mode by calling the edit() method.

@FXML
public void rename() {
    int index = lvProfiles.getSelectionModel().getSelectedIndex();
    lvProfiles.edit(index);
}

The same edit() call is made from the On Key Pressed handler.  There is some extra code in here to handle the Delete use case.

@FXML
public void handleProjectBrowserKey(KeyEvent evt) {
    if (StringUtils.isNotEmpty(lvProfiles.getSelectionModel().getSelectedItem())) {

        switch (evt.getCode()) {
            case DELETE:
                deleteProfile();
                break;
            case F2:
                int index = lvProfiles.getSelectionModel().getSelectedIndex();
                lvProfiles.edit(index);
                break;
        }
    }
}

There is no need to write a handler for the double-click event; that's part of the default behavior of the ListView when the TextFieldListCell was specified.

After Commit Logic and Saving

The On Edit Commit handler is an @FXML method.  This is called after the user types in a name and presses the Enter key. I'm going to present the method in chunks.  For a full code listing, go to the GitHub URL at the start of this post.

@FXML
public void renameProfile(ListView.EditEvent evt) {
...

In the following code blocks, configurationDS is a data access object (DAO) that serializes the application to a JSON file.  The DAO works with an ActiveRecord ("activeProfile") which contains the currently-displayed Profile.  My logic needs to work with the case where the rename operation is changing the currently-loaded record.

Ensure Unique Name

The method starts with a check to make sure that the name is unique.  As in Windows Explorer, if the name is not unique, the user is presented with an option to save under a suggested name.  For example, if the user attempts to rename something "AAA" where "AAA" already exists, the user is presented with an option to save as AAA-2.  This will carry on if there happens to also be an AAA-2 to the next increment (AAA-3) and so on if there are future conflicts.

final String oldProfileName = lvProfiles.getItems().get(evt.getIndex());
final boolean apProfileNameSetFlag = StringUtils.equalsIgnoreCase(activeProfile.getProfileName(), oldProfileName);

String suggestedNewProfileName = "";
if (configurationDS.profileExists(evt.getNewValue())) {

    suggestedNewProfileName = configurationDS.suggestUniqueProfileName(evt.getNewValue());

    Alert alert = new Alert(
            Alert.AlertType.CONFIRMATION,
            "That profile name already exists." + System.getProperty("line.separator") +
                    "Save as " + suggestedNewProfileName + "?");
    alert.setHeaderText("Profile name in use");
    Optional response = alert.showAndWait();
    if (!response.isPresent() || response.get() != ButtonType.OK) {
        return;
    }
}

final String newProfileName = StringUtils.defaultIfBlank(suggestedNewProfileName, evt.getNewValue());

if (apProfileNameSetFlag) {  // needs to be set for save
    activeProfile.setProfileName(newProfileName);
}

apProfileNameSetFlag is used later in case I need to back out the newProfileName.  We haven't called any of the storage methods yet.  Since they are the most likely to fail, I need a way to back-out the setProfileName() call above.

If there was no conflict, then newProfileName is the name entered in the ListView control.  Otherwise, it will be the suggested item.

Saving

The remaining code is a Task.  I put everything >300ms in a Task so that the UI remains responsive.  Using the Task, I'll keep away the spinning rainbow circles of the Mac or the dreaded "Not Responding" on the Windows dialog title bar.  In practical terms, I'm not timing the methods but defensively adding a Task to methods with the potential to take longer: file operations, networking, etc.

Task<Void> renameTask = new Task<Void>() {

    @Override
    protected Void call() throws Exception {
        configurationDS.renameProfile(oldProfileName, newProfileName);
        Platform.runLater(() -> {
            lvProfiles.getItems().set(evt.getIndex(), newProfileName);

            Collections.sort(lvProfiles.getItems());
            lvProfiles.getSelectionModel().select(newProfileName);

        });
        return null;
    }

The call() method invokes the data storage methods of the DAO (configurationDS).  It's followed up by some statements that update the UI.  They must be wrapped in a runLater() so that they will be invoked on the FX Thread.

Note that the Collections.sort() call on the ObservableList will result in a firing of the selection event.  This is actually a deselection event as the handler will receive a "null" for the new value.

The failed() method is called if the configurationDS.renameProfile() call throws an exception.  This is logged and presented to the user in a popup dialog.  Note the use of the previously-defined flag to revert a setting on the activeProfile if the rename operation didn't go all the way through.

    @Override
    protected void failed() {
        super.failed();
        logger.error("can't rename profile from " + oldProfileName + " to " + newProfileName, getException());
        Platform.runLater(() -> {
            Alert alert = new Alert(
                    Alert.AlertType.ERROR,
                    getException().getMessage());
            alert.setHeaderText("Can't rename profile '" + oldProfileName + "'");
            alert.showAndWait();

            if (apProfileNameSetFlag) {  // revert
                activeProfile.setProfileName(oldProfileName);
            }
        });
    }

The cancelled() method is added in case I want to support cancelling.  By default, an escape pressed while editing discards the changes.  If the user initiates the rename and then presses a Cancel button (not implemented), this method can be invoked if the user were to kill a task.  This is more for a future requirement involving a Cloud datastore where I'll check the saving status within the DAO which will give me an interrupt point.

    @Override
    protected void cancelled() {
        super.cancelled();
        Platform.runLater(() -> {
            Alert alert = new Alert(
                    Alert.AlertType.ERROR,
                    "Rename cancelled by user");
            alert.setHeaderText("Cancelled");
            alert.showAndWait();

            if (apProfileNameSetFlag) {  // revert
                activeProfile.setProfileName(oldProfileName);
            }
        });
    }
};

new Thread(renameTask).start();

Most of this is off-the-shelf ListView code.  But there are a few techniques like checking for uniqueness and threading that can help make the rename feature behave as the user expects.  The full source is available on GitHub if you're curious about the DAO and ActiveRecord patterns I'm following that are outside of this specific example.

No comments:

Post a Comment