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.
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 |
ListView Contains ContextMenu Items |
Editable ListView Setting |
On Edit Commit ListView Setting |
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.EditEventevt) { ...
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"); Optionalresponse = 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