官术网_书友最值得收藏!

Building the graphical user interface

For our GUI, we'd like to expose the same type of functionality as the command line, but, obviously, with a nice graphical interface. For this, we'll again reach for JavaFX. We'll give the user a means to select, using a chooser dialog, the directories to be searched, and a field by which to add the search patterns. Once the duplicates have been identified, we will display them in a list for the user to peruse. All of the duplicate groups will be listed and, when clicked, the files in that group will be displayed in another list. The user can right-click on the list and choose to either view the file (or files) or delete it (or them). When we are finished, the application will look like this:

Let's start by creating our project. In NetBeans, go to File | New Project and select Maven | JavaFX Application. You can name it whatever you'd like, but we've used the name Duplicate Finder - GUI, groupId as com.steeplesoft.dupefind, and artifactId as gui.

Once you have your project, you should have two classes, Main and FXMLController, as well as the fxml/Scene.fxml resource. This may sound repetitive, but before we go any further, we need to set up our Java module as follows:

    module dupefind.gui { 
      requires dupefind.lib; 
      requires java.logging; 
      requires javafx.controls; 
      requires javafx.fxml; 
      requires java.desktop; 
    } 

Then, to create the interface we saw, we will use BorderPane, to which we'll add MenuBar to the top section, as follows:

    <top> 
      <MenuBar BorderPane.alignment="CENTER"> 
        <menus> 
          <Menu mnemonicParsing="false"  
            onAction="#closeApplication" text="File"> 
            <items> 
              <MenuItem mnemonicParsing="false" text="Close" /> 
            </items> 
          </Menu> 
          <Menu mnemonicParsing="false" text="Help"> 
            <items> 
              <MenuItem mnemonicParsing="false"  
                onAction="#showAbout" text="About" /> 
            </items> 
          </Menu> 
        </menus> 
      </MenuBar> 
    </top> 

When you add MenuBar with Scene Builder, it automatically adds several sample Menu entries for you. We've removed the unwanted entries, and tied the remaining to Java methods in the controller class. Specifically, the Close menu will call closeApplication() and About will call showAbout(). This looks just like the menu markup seen previously in the book, so there's not much to talk about.

The rest of the layout is a bit more complex. In the left section, we have a number of controls stacked vertically. JavaFX has a built-in container that makes that easy to do: VBox. We'll get to its contents in a moment, but its usage looks like this:

    <VBox BorderPane.alignment="TOP_CENTER"> 
      <children> 
         <HBox... /> 
         <Separator ... /> 
         <Label .../> 
         <ListView ... /> 
         <HBox ... /> 
         <Label ... /> 
         <ListView... /> 
         <HBox ... /> 
      </children> 
      <padding> 
         <Insets bottom="10.0" left="10.0" right="10.0" 
top="10.0" /> </padding> </VBox>

That's not valid FXML, so don't try to copy and paste that. I've omitted the details of the children for clarity. As you can see, VBox has a number of children, each of which will be stacked vertically, but, as we can see from the preceding screenshot, there are some we want to be lined up horizontally. To achieve that, we nest an HBox instance where needed. Its markup looks just like VBox.

There's not much of interest in this part of the FXML, but there are a couple of things to note. We want certain parts of the user interface to shrink and grow as the window is resized, namely ListView. By default, each component's various height and width properties--minimum, maximum, and preferred--will use the computed size, which means, roughly, that they'll be as big as they need to be to render themselves, and, in most cases, that's fine. In our situation, we want the two ListView instances to grow as much as possible inside their respective containers, which, in this case, is VBox we discussed earlier. To make that happen, we need to modify our two ListView instances like this:

    <ListView fx:id="searchPatternsListView" VBox.vgrow="ALWAYS" /> 
    ... 
    <ListView fx:id="sourceDirsListView" VBox.vgrow="ALWAYS" /> 

With both the ListView instances set to ALWAYS grow, they will compete with each other for the available space, and end up sharing it. The available space, of course, is dependent on the height of the VBox instance, as well as the computed height of the other components in the container. With that property set, we can increase or decrease the size of the window, and watch the two ListView instances grow and shrink, while everything else remains the same.

For the rest of the user interface, we'll apply the same tactic to arrange components, but, this time, we'll start with an HBox instance, and divide that up as necessary. We have two ListView instances that we also want to fill all the available space with, so we mark those up in the same way we did the last two. Each ListView instance also has a Label, so we wrap each Label/ListView pair in a VBox instance to get our vertical distribution. In pseudo-FXML, this would look like this:

    <HBox> 
      <children> 
         <Separator orientation="VERTICAL"/> 
         <VBox HBox.hgrow="ALWAYS"> 
           <children> 
             <VBox VBox.vgrow="ALWAYS"> 
                <children> 
                  <Label ... /> 
                  <ListView ... VBox.vgrow="ALWAYS" /> 
                </children> 
             </VBox> 
           </children> 
         </VBox> 
         <VBox HBox.hgrow="ALWAYS"> 
           <children> 
             <Label ... /> 
             <ListView ... VBox.vgrow="ALWAYS" /> 
           </children> 
         </VBox> 
      </children> 
    </HBox> 

There is one item of interest in this part of the user interface, and that is the context menu we discussed earlier. To add a context to a control, you nest a contextMenu element in the target control's FXML like this:

    <ListView fx:id="matchingFilesListView" VBox.vgrow="ALWAYS"> 
      <contextMenu> 
        <ContextMenu> 
          <items> 
            <MenuItem onAction="#openFiles" text="Open File(s)..." /> 
            <MenuItem onAction="#deleteSelectedFiles"  
              text="Delete File(s)..." /> 
           </items> 
         </ContextMenu> 
      </contextMenu> 
    </ListView> 

We've defined a content menu with two MenuItem: "Open File(s)..." and "Deleted File(s)...". We've also specified the action for the two MenuItem using the onAction attribute. We'll look at these following methods.

This marks the end of our user interface definition, so now we turn our attention to the Java code, in which we will finish preparing the user interface for use, as well as implement our application's logic.

While we didn't show the FXML that accomplishes this, our FXML file is tied to our controller class: FXMLController. This class can be called anything, of course, but we've opted to use the name generated by the IDE. In a larger application, more care will need to be given in the naming of this class. To allow the injection of our user interface components into our code, we need to declare instance variables on our class, and mark them up with the @FXML annotation. Some examples include the following:

    @FXML 
    private ListView<String> dupeFileGroupListView; 
    @FXML 
    private ListView<FileInfo> matchingFilesListView; 
    @FXML 
    private Button addPattern; 
    @FXML 
    private Button removePattern; 

There are several others, but this should be sufficient to demonstrate the concept. Note that rather than declaring a plain ListView, we've parameterized our instances as ListView<String> and ListView<FileInfo>. We know this is what we're putting into the control, so specifying that the type parameter gets us a measure of type safety at compile time, but also allows us to avoid having to cast the contents every time we interact with them.

Next, we need to set up the collections that will hold the search paths and patterns that the user will enter. We'll use the ObservableList instances for that. Remember that with an ObservableList instance, the container can automatically rerender itself as needed when the Observable instance is updated:

    final private ObservableList<String> paths =  
      FXCollections.observableArrayList(); 
    final private ObservableList<String> patterns =  
      FXCollections.observableArrayList(); 

In the initialize() method, we can start tying things together. Consider the following code snippet as an example:

    public void initialize(URL url, ResourceBundle rb) { 
      searchPatternsListView.setItems(patterns); 
      sourceDirsListView.setItems(paths); 

Here, we associate our ListView instances with our ObservableList instances. Now, at any point that these lists are updated, the user interface will immediately reflect the change.

Next, we need to configure the duplicate file group ListView. The data coming back from our library is a Map of a List<FileInfo> object, keyed by the duplicate hashes. Clearly, we don't want to show the user a list of hashes, so, like the CLI, we want to denote each group of files with a more friendly label. To do that, we need to create a CellFactory, which will, in turn, create a ListCell that is responsible for rendering the cell. We will do that as follows:

    dupeFileGroupListView.setCellFactory( 
      (ListView<String> p) -> new ListCell<String>() { 
        @Override 
        public void updateItem(String string, boolean empty) { 
          super.updateItem(string, empty); 
          final int index = p.getItems().indexOf(string); 
          if (index > -1) { 
            setText("Group #" + (index + 1)); 
          } else { 
            setText(null); 
          } 
       } 
    }); 

While lambdas can be great, in that they tend to make code more concise, they can also obscure some details. In a non-lambda code, the lambda above might look like this:

    dupeFileGroupListView.setCellFactory(new  
      Callback<ListView<String>, ListCell<String>>() { 
        @Override 
        public ListCell<String> call(ListView<String> p) { 
          return new ListCell<String>() { 
            @Override 
            protected void updateItem(String t, boolean bln) { 
             super.updateItem(string, empty); 
              final int index = p.getItems().indexOf(string); 
              if (index > -1) { 
                setText("Group #" + (index + 1)); 
              } else { 
                setText(null); 
              } 
            } 
          }; 
        } 
    }); 

You certainly get more detail, but it's also much harder to read. The main point in including both here is twofold: to show why lambdas are often so much better, and to show the actual types involved, which helps the lambdas make sense. With that understanding of the lambdas under our belts, what is the method doing?

First, we call super.updateItem(), as that's simply good practice. Next, we find the index of the string being rendered. The API gives us the string (since it's a ListView<String>), so we find its index in our ObservableList<String>. If it's found, we set the text of the cell to Group # plus the index plus one (since indexes in Java are typically zero-based). If the string isn't found (ListView is rendering an empty cell), we set the text to null to ensure that the field is blank.

Next, we need to perform a similar procedure on matchingFilesListView:

    matchingFilesListView.getSelectionModel() 
      .setSelectionMode(SelectionMode.MULTIPLE); 
    matchingFilesListView.setCellFactory( 
      (ListView<FileInfo> p) -> new ListCell<FileInfo>() { 
        @Override 
        protected void updateItem(FileInfo fileInfo, boolean bln) { 
          super.updateItem(fileInfo, bln); 
          if (fileInfo != null) { 
             setText(fileInfo.getPath()); 
          } else { 
             setText(null); 
          } 
        } 
    }); 

This is almost identical, but with a couple of exceptions. First, we're setting the selection mode of ListView to MULTIPLE. This will allow the user to control-click on items of interest, or shift-click on a range of rows. Next, we set up CellFactory in an identical fashion. Note that since the ListView instance's parameterized type is FileInfo, the types in the method signature of ListCell.updateItem() are different.

We have one last user interface setup step. If you look back at the screenshot, you will notice that the Find Duplicates button is the same width as ListView, unlike the other buttons, which are just wide enough to render their content. We do that by binding the width of the Button element to that of its container, which is an HBox instance:

    findFiles.prefWidthProperty().bind(findBox.widthProperty()); 

We are getting the preferred width property, which is a DoubleProperty, and binding that to the width property (also a DoubleProperty) of findBox, the control's container. DoubleProperty is an Observable instance, just as ObservableListView is, so we're telling the findFiles control to observe its container's width property, and set its own value accordingly when the other changes. This lets us set the property, after a fashion, and then forget about it. Unless we want to break the binding between these two properties, we never again have to think about it, and we certainly don't need to manually watch one property to update the author. The framework does that for us.

Now, how about those buttons? How do we make them do something? We do that by setting the onAction property of the Button element to a method in our controller: #someMethod translates to Controller.someMethod(ActionEvent event). We can handle this in one of at least two ways: we can create a separate handler method for each button, or, as we've done here, we can create one, then delegate to another method as appropriate; either is fine:

    @FXML 
    private void handleButtonAction(ActionEvent event) { 
      if (event.getSource() instanceof Button) { 
        Button button = (Button) event.getSource(); 
        if (button.equals(addPattern)) { 
          addPattern(); 
        } else if (button.equals(removePattern)) { 
        // ... 

We have to make sure we're actually getting a Button element, then we cast it and compare it to the instances that were injected. The actual handlers for each button are as follows:

    private void addPattern() { 
      TextInputDialog dialog = new TextInputDialog("*.*"); 
      dialog.setTitle("Add a pattern"); 
      dialog.setHeaderText(null); 
      dialog.setContentText("Enter the pattern you wish to add:"); 

      dialog.showAndWait() 
      .filter(n -> n != null && !n.trim().isEmpty()) 
      .ifPresent(name -> patterns.add(name)); 
    } 

To add a pattern, we create a TextInputDialog instance with the appropriate text, then call showAndWait(). The beauty of this method in JavaFX 8 is that it returns Optional<String>. If the user enters text in the dialog, and if the user clicks on OK, the Optional will have content. We identify that with the call to ifPresent(), passing it a lambda that adds the new pattern to ObservableList<String>, which automatically updates the user interface. If the user doesn't click on OK, the Optional will be empty. If the user didn't enter any text (or entered a bunch of spaces), the call to filter() will prevent the lambda from ever running.

Removing an item is similar, though we get to hide some of the details in a utility method, since we have two needs for the functionality. We make sure something is selected, then show a confirmation dialog, removing the pattern from the ObservableList<String> if the user clicks on OK:

    private void removePattern() { 
      if (searchPatternsListView.getSelectionModel() 
      .getSelectedIndex() > -1) { 
        showConfirmationDialog( 
          "Are you sure you want to remove this pattern?", 
          (() -> patterns.remove(searchPatternsListView 
          .getSelectionModel().getSelectedItem()))); 
      } 
    } 

Let's take a look at the showConfirmationDialog method:

    protected void showConfirmationDialog(String message, 
     Runnable action) { 
      Alert alert = new Alert(Alert.AlertType.CONFIRMATION); 
      alert.setTitle("Confirmation"); 
      alert.setHeaderText(null); 
      alert.setContentText(message); 
      alert.showAndWait() 
      .filter(b -> b == ButtonType.OK) 
      .ifPresent(b -> action.run()); 
    } 

Again, this is much like the dialogs earlier, and should be self-explanatory. The interesting part here is the use of a lambda as a method parameter that makes this, by the way, a higher order function--meaning it takes in a function as a parameter, returns a function as its result, or both. We pass in Runnable, as we want a lambda that takes in nothing and returns nothing, and Runnable is a FunctionalInterface that matches that description. After we show the dialog and get the user's response, we will filter for only responses where the button clicked on was OK, and, if present, we execute Runnable via action.run(). We have to specify b -> action.run() as ifPresent() takes a Consumer<? super ButtonType>, so we create one and ignore the value passed in, allowing us to shield our calling code from that detail.

Adding a path requires a DirectoryChooser instance:

    private void addPath() { 
        DirectoryChooser dc = new DirectoryChooser(); 
        dc.setTitle("Add Search Path"); 
        dc.setInitialDirectory(new File(lastDir)); 
        File dir = dc.showDialog(null); 
        if (dir != null) { 
            try { 
                lastDir = dir.getParent(); 
                paths.add(dir.getCanonicalPath()); 
            } catch (IOException ex) { 
                Logger.getLogger(FXMLController.class.getName()).log(
Level.SEVERE, null, ex); } } }

When creating the DirectoryChooser instance, we set the initial directory to the last directory used as a convenience for the user. When the application starts, this defaults to the user's home directory, but once a directory is successfully chosen, we set lastDir to the added directory's parent, allowing the user to start where he or she left off should there be a need to enter multiple paths. DirectoryChooser.showDialog() returns a file, so we get its canonical path and store that in paths, which, again, causes our user interface to be updated automatically.

Removing a path looks very similar to removing a pattern, as you can see in the following code snippet:

    private void removePath() { 
      showConfirmationDialog( 
        "Are you sure you want to remove this path?", 
        (() -> paths.remove(sourceDirsListView.getSelectionModel() 
        .getSelectedItem()))); 
    } 

Same basic code, just a different lambda. Aren't lambdas just the coolest?

The handler for the findFiles() button is a bit different, but looks a lot like our CLI code, as you can see here:

    private void findFiles() { 
       FileFinder ff = new FileFinder(); 
       patterns.forEach(p -> ff.addPattern(p)); 
       paths.forEach(p -> ff.addPath(p)); 
 
       ff.find(); 
       dupes = ff.getDuplicates(); 
       ObservableList<String> groups =  
         FXCollections.observableArrayList(dupes.keySet()); 
 
       dupeFileGroupListView.setItems(groups); 
    } 

We create our FileFinder instance, set the paths and patterns using streams and lambdas, then start the search process. When it completes, we get the list duplicate file information via getDuplicates(), then create a new ObservableList<String> instance using the keys of the map, which we then set on dupeFileGroupListView.

Now we need to add the logic to handle mouse clicks on the group list, so we will set the onMouseClicked property on ListView in the FXML file to #dupeGroupClicked, as you can see in the following code block:

    @FXML 
    public void dupeGroupClicked(MouseEvent event) { 
      int index = dupeFileGroupListView.getSelectionModel() 
       .getSelectedIndex(); 
      if (index > -1) { 
        String hash = dupeFileGroupListView.getSelectionModel() 
        .getSelectedItem(); 
        matchingFilesListView.getItems().clear(); 
        matchingFilesListView.getItems().addAll(dupes.get(hash)); 
      } 
    } 

When the control is clicked on, we get the index and make sure it is non-negative, so as to ensure that the user actually clicked on something. We then get the hash of the group by getting the selected item from ListView. Remember that while ListView may show something like Group #2, the actual content of that row is the hash. We just used a custom CellFactory to give it a prettier label. With the hash, we clear the list of items in matchingFilesListView, then get the control's ObservableList and add all of the FileInfo objects in the List keyed by the hash. And, again, we get an automatic user interface update, thanks to the power of Observable.

We also want the user to be able to navigate the list of duplicate groups using the keyboard to update the matching file list. We do that by setting the onKeyPressed attribute on our ListView to point to this rather simple method:

    @FXML 
    public void keyPressed(KeyEvent event) { 
      dupeGroupClicked(null); 
    } 

It just so happens that we're not too terribly interested in the actual Event in either of these methods (they're never actually used), so we can naively delegate to the mouse-click method discussed earlier.

There are two more minor pieces of functionality we need to implement: viewing the matching files and deleting matching files.

We've already created the context menu and menu entries, so all we need to do is implement the handler methods as follows:

    @FXML 
    public void openFiles(ActionEvent event) { 
      matchingFilesListView.getSelectionModel().getSelectedItems() 
      .forEach(f -> { 
        try { 
          Desktop.getDesktop().open(new File(f.getPath())); 
        } catch (IOException ex) { 
          // ... 
        } 
      }); 
    } 

The matching file list allows multiple selections, so we need to get List<FileInfo> from the selection model instead of the single object we've already seen. We then call forEach() to process the entry. We want to open the file in whatever application the user has configured in the operating system to handle that file type. To do this, we use an AWT class introduced in Java 6: Desktop. We get the instance via getDesktop(), then call open(), passing it File that points to our FileInfo target.

Deleting a file is similar:

    @FXML 
    public void deleteSelectedFiles(ActionEvent event) { 
      final ObservableList<FileInfo> selectedFiles =  
        matchingFilesListView.getSelectionModel() 
        .getSelectedItems(); 
      if (selectedFiles.size() > 0) { 
        showConfirmationDialog( 
          "Are you sure you want to delete the selected files", 
           () -> selectedFiles.forEach(f -> { 
            if (Desktop.getDesktop() 
            .moveToTrash(new File(f.getPath()))) {                         
              matchingFilesListView.getItems() 
              .remove(f); 
              dupes.get(dupeFileGroupListView 
               .getSelectionModel() 
               .getSelectedItem()).remove(f); 
            } 
        })); 
      } 
    } 

Similarly to open files, we get all of the selected files. If there's at least one, we confirm the user's intent via showConfirmationDialog(), and pass in a lambda that handles the deleting. We do the actual file deletion using the Desktop class again to move the file to the trash can provided by the filesystem to provide the user with a safe delete option. If the file is successfully deleted, we remove its entry from ObservableList, as well as our cache duplicate file Map, so that it isn't shown should the user click on this file group again.

主站蜘蛛池模板: 顺平县| 焉耆| 柳江县| 夏河县| 清流县| 塘沽区| 永宁县| 泸溪县| 荔波县| 行唐县| 沾益县| 新干县| 通州区| 体育| 天门市| 巴中市| 筠连县| 柳州市| 宜丰县| 阳高县| 滦南县| 旬邑县| 六盘水市| 驻马店市| 错那县| 保山市| 秭归县| 绥芬河市| 交口县| 图木舒克市| 囊谦县| 古交市| 罗城| 兴城市| 封丘县| 黄梅县| 穆棱市| 仁化县| 会理县| 五台县| 元氏县|