package com.twasyl.slideshowfx.controls.tree; import com.twasyl.slideshowfx.engine.template.TemplateEngine; import com.twasyl.slideshowfx.ui.controls.ExtendedTextField; import com.twasyl.slideshowfx.ui.controls.validators.Validators; import com.twasyl.slideshowfx.utils.DialogHelper; import com.twasyl.slideshowfx.utils.io.DeleteFileVisitor; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.PseudoClass; import javafx.event.EventHandler; import javafx.scene.Node; import javafx.scene.control.ButtonType; import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; import javafx.scene.input.DragEvent; import javafx.scene.input.Dragboard; import javafx.scene.input.MouseEvent; import javafx.scene.input.TransferMode; import javafx.scene.layout.HBox; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.logging.Level; import java.util.logging.Logger; /** * This class is the TreeView that is used to managed the content of a template archive. * Because this TreeView represents the current content of a template archive, methods like * {@link #appendContentToTreeView(File, TreeItem)} or * {@link #deleteContentOfTreeView(TreeItem)} also perform operations on the * filesystem. * * @author Thierry Wasylczenko * @version 1.2 * @since SlideshowFX 1.0 */ public class TemplateTreeView extends TreeView<File> { private final Logger LOGGER = Logger.getLogger(TemplateTreeView.class.getName()); private static final PseudoClass VALID_DRAG = PseudoClass.getPseudoClass("validDrag"); private static final PseudoClass INVALID_DRAG = PseudoClass.getPseudoClass("invalidDrag"); private final ObjectProperty<EventHandler<MouseEvent>> onItemClick = new SimpleObjectProperty<>(); /** * This method is used when something is drag over the TreeView that shows the content of the template's archive. * Only files are accepted. * * @param dragEvent The event associated to the drag */ private EventHandler<DragEvent> onDragOverItem = dragEvent -> { this.removeCustomPseudoClass((Node) dragEvent.getSource()); if (dragEvent.getDragboard().hasFiles()) { dragEvent.acceptTransferModes(TransferMode.COPY); ((Node) dragEvent.getSource()).pseudoClassStateChanged(VALID_DRAG, true); if (dragEvent.getSource() instanceof TreeCell) { final TreeCell cell = (TreeCell) dragEvent.getSource(); if (cell != null) { cell.getTreeItem().setExpanded(true); } } } else { ((Node) dragEvent.getSource()).pseudoClassStateChanged(INVALID_DRAG, true); } dragEvent.consume(); }; /** * This method is used when something is dropped on the TreeView that shows the content of the template's archive. * Only files are accepted. * * @param dragEvent The event associated to the drag */ private EventHandler<DragEvent> onDragDroppedItem = dragEvent -> { Dragboard board = dragEvent.getDragboard(); boolean dragSuccess = false; if (board.hasFiles()) { board.getFiles() .stream() .forEach(file -> { final TreeItem<File> item = dragEvent.getSource() == this ? this.getRoot() : ((TreeCell<File>) dragEvent.getSource()).getTreeItem(); this.appendContentToTreeView(file, item); }); dragSuccess = true; } this.removeCustomPseudoClass((Node) dragEvent.getSource()); dragEvent.setDropCompleted(dragSuccess); dragEvent.consume(); }; /** * This method is used when the drag is done on the TreeView that shows the content of the template's archive. * Only files are accepted. * * @param dragEvent The event associated to the drag */ private EventHandler<DragEvent> onDragDoneItem = dragEvent -> { removeCustomPseudoClass((Node) dragEvent.getSource()); }; /** * This method is used when the drag exits the TreeView that shows the content of the template's archive. * Only files are accepted. * * @param dragEvent The event associated to the drag */ private EventHandler<DragEvent> onDragExitedItem = dragEvent -> { removeCustomPseudoClass((Node) dragEvent.getSource()); }; private final ObjectProperty<TemplateEngine> engine = new SimpleObjectProperty<>(); public TemplateTreeView() { super(); this.init(); } public TemplateTreeView(TreeItem<File> root) { super(root); this.init(); } private final void init() { this.getStyleClass().add("template-tree-view"); this.setOnDragDropped(this.onDragDroppedItem); this.setOnDragEntered(this.onDragOverItem); this.setOnDragDone(this.onDragDoneItem); this.setOnDragExited(this.onDragExitedItem); this.setCellFactory((TreeView<File> p) -> { FileTreeCell cell = new FileTreeCell(); if (this.getOnItemClick() != null) cell.setOnMouseClicked(this.getOnItemClick()); return cell; }); } /** * Get the event handler that is registered to newly created TreeItem in this TreeView. * * @return Get the event handler that is registered to newly created TreeItem in this TreeView. */ public ObjectProperty<EventHandler<MouseEvent>> onItemClickProperty() { return this.onItemClick; } /** * Get the event handler that is registered to newly created TreeItem in this TreeView. * * @return Get the event handler that is registered to newly created TreeItem in this TreeView. */ public EventHandler<MouseEvent> getOnItemClick() { return onItemClick.get(); } /** * Set the event handler that is registered to newly created TreeItem in this TreeView. * * @param onItemClick the new event that will be added to newly created TreeItems in this TreeView. */ public void setOnItemClick(EventHandler<MouseEvent> onItemClick) { this.onItemClick.set(onItemClick); } /** * This method adds the given file to the selected item in the tree view. If there is no selection, the root of the * tree view will be used. * If the file is a directory, all files included in the directory will be added to the tree view for a TreeItem * corresponding the the current given file. * This method also copy the given file to the temporary archive folder. * * @param file The content to add to the TreeView. * @return Return the {@link TreeItem} that has been created. */ public TreeItem<File> appendContentToTreeView(File file) { final TreeItem<File> parent = this.getParentDirectoryOfSelection(); return this.appendContentToTreeView(file, parent); } /** * This method adds the given file to the parent {@link TreeItem}. If the file is a directory, * all files included in the directory will be added to the TreeView for a TreeItem corresponding the the current given file. * This method also copy the given file to the temporary archive folder. * * @param file The content to add to the TreeView. * @param parent The item that is the parent of the content to add. * @return Return the {@link TreeItem} that has been created. */ public TreeItem<File> appendContentToTreeView(File file, TreeItem<File> parent) { File relativeToParent = new File(parent.getValue(), file.getName()); TreeItem<File> treeItem = new TreeItem<>(relativeToParent); try { if (file.isDirectory()) { Files.createDirectories(relativeToParent.toPath()); for (final File child : file.listFiles()) { this.appendContentToTreeView(child, treeItem); } } else { Files.copy(file.toPath(), relativeToParent.toPath()); } parent.getChildren().add(treeItem); this.getSelectionModel().select(treeItem); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Can not copy content", e); treeItem = null; } return treeItem; } /** * This methods will prompt the user a name of a file and create an empty file under the current selection. */ public void promptUserAndCreateNewFile() { final ExtendedTextField fileName = new ExtendedTextField("File name", true); fileName.setValidator(Validators.isNotEmpty()); final HBox pane = new HBox(5, fileName); final ButtonType answer = DialogHelper.showCancellableDialog("Add a new file", pane); if (answer == ButtonType.OK && fileName.isValid()) { this.createFileUnderSelection(fileName.getText()); } } /** * This methods will prompt the user a name of a file and create an empty file under the current selection. */ public void promptUserAndCreateNewDirectory() { final ExtendedTextField directoryName = new ExtendedTextField("Directory name", true); directoryName.setValidator(Validators.isNotEmpty()); final HBox pane = new HBox(5, directoryName); final ButtonType answer = DialogHelper.showCancellableDialog("Add a new directory", pane); if (answer == ButtonType.OK && directoryName.isValid()) { this.createDirectoryUnderSelection(directoryName.getText()); } } /** * Creates an empty file named according the given {@code fileName}. The file is created under the current selection. * If the current selection is a directory, an empty file will be created inside this directory. * If the current selection is a file, the first parent will be determined and the file will be created into this * parent. * If there is no selection, then the file will be created under the root. * * @param fileName The name of the file that must be created. */ public void createFileUnderSelection(final String fileName) { if (fileName != null && !fileName.trim().isEmpty()) { final TreeItem<File> parent = getParentDirectoryOfSelection(); if (parent != null) { final File newFile = new File(parent.getValue(), fileName.trim()); try { Files.createFile(newFile.toPath()); final TreeItem<File> newFileItem = new TreeItem<>(newFile); parent.getChildren().add(newFileItem); this.getSelectionModel().select(newFileItem); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Can not create the empty file", e); } } else { LOGGER.log(Level.WARNING, "Can not determine where the file must be created"); } } } /** * Creates an empty directory named according the given {@code directoryName}. The file is created under the * current selection. * If the current selection is a directory, an empty directory will be created inside this directory. * If the current selection is a file, the first parent will be determined and the directory will be created into * this parent. * If there is no selection, then the directory will be created under the root. * <p> * The directory's name can be a path where each directory to create is separated by a {@code /}. For instance: * {@code dir/subdir} will create a subdir directory in a dir directory, itself created under the selection. * * @param directoryName The name of the directory that must be created. */ public void createDirectoryUnderSelection(final String directoryName) { final TreeItem<File> parent = getParentDirectoryOfSelection(); if (directoryName != null && !directoryName.trim().isEmpty() && parent != null) { final String[] directories = directoryName.trim().split("/"); TreeItem<File> createdDirectory = parent; int index = 0; do { createdDirectory = this.createDirectoryUnderParent(createdDirectory, directories[index++]); } while (createdDirectory != null && index < directories.length); } } /** * Creates an empty directory under the given parent. The parent must not be {@code null} and it's value must be * non {@code null} and be a directory. * The name of the directory must be non {@code null} and not empty. * * @param parent The parent of the directory to create. * @param directoryName The name of the directory to create. * @return Return the created directory. */ private TreeItem<File> createDirectoryUnderParent(final TreeItem<File> parent, final String directoryName) { TreeItem<File> newDirectoryItem = null; if (directoryName != null && !directoryName.trim().isEmpty()) { if (parent != null && parent.getValue() != null) { if (parent.getValue().isDirectory()) { final File newDirectory = new File(parent.getValue(), directoryName.trim()); if (!newDirectory.exists()) { try { Files.createDirectory(newDirectory.toPath()); newDirectoryItem = new TreeItem<>(newDirectory); parent.getChildren().add(newDirectoryItem); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Can not create the empty file", e); } } else { newDirectoryItem = parent.getChildren() .stream() .filter(item -> item.getValue().equals(newDirectory)) .findFirst() .orElse(null); } if (newDirectoryItem != null) { this.getSelectionModel().select(newDirectoryItem); } } else { LOGGER.log(Level.WARNING, "Can not create a directory because the parent is not a directory"); } } else { LOGGER.log(Level.WARNING, "Can not determine where the file must be created"); } } return newDirectoryItem; } /** * Determine the parent of the current selection that is a directory. If the selection is a directory itself, * then it is returned. If the selection is a file, then it's parent is returned. If there is no selection, the * root of this {@link TemplateTreeView} is returned. * * @return The parent of the selection that is a directory. */ protected TreeItem<File> getParentDirectoryOfSelection() { final TreeItem<File> selection = this.getSelectionModel().getSelectedItem(); final TreeItem<File> parent; if (selection == null) { parent = getRoot(); } else if (selection.getValue() != null && selection.getValue().isDirectory()) { parent = selection; } else if (selection.getValue() != null && selection.getValue().isFile()) { parent = selection.getParent(); } else { parent = null; } return parent; } /** * Closes recursively the given {@link TreeItem}. * * @param item The item to close recusively. */ public void closeItem(final TreeItem<File> item) { if (item != null) { item.setExpanded(false); if(!item.isLeaf()) { item.getChildren().forEach(this::closeItem); } } } /** * Deletes the given item of the TreeView. If the item is the root or the template configuration item * of the {@link #engineProperty()}, it won't be deleted. * Note that this method also removes the file contained in the given item from the file system. * * @param item The item to remove from this TreeView. * @throws NullPointerException If the item is null. * @throws IOException If an error occured when trying to delete the file corresponding to the item. */ public void deleteContentOfTreeView(TreeItem<File> item) throws NullPointerException, IOException { if (item == null) throw new NullPointerException("The item can not be null"); if (isItemDeletionEnabled(item)) { if (item.getValue().exists()) { if (item.getValue().isFile()) { Files.delete(item.getValue().toPath()); } else { Files.walkFileTree(item.getValue().toPath(), new DeleteFileVisitor()); } } item.getParent().getChildren().remove(item); } } /** * This method renames the given item with the new name. This method also renames the File * attached to this item on the file system. * Note that if the item is the root or the configuration file for the {@link #engineProperty()}, nothing is performed. * * @param item The item to rename. * @param name The new name of this item and file. * @throws NullPointerException If the item or the name is null. * @throws IllegalArgumentException If the name is empty. * @throws IOException If an error occured while renaming the file associated to this item. */ public void renameContentOfTreeView(TreeItem<File> item, String name) throws NullPointerException, IllegalArgumentException, IOException { if (item == null) throw new NullPointerException("The item can not be null"); if (name == null) throw new NullPointerException("The new name can not be null"); if (name.isEmpty()) throw new IllegalArgumentException("The new name can not be empty"); if (isItemRenamingAllowed(item)) { final Path currentFile = item.getValue().toPath(); final File newFile = new File(item.getValue().getParent(), name); Files.move(currentFile, currentFile.resolveSibling(name)); item.setValue(newFile); } } /** * Tests if the given item is allowed to be renamed. The root and the configuration item can not be renamed. * * @param item The item to test the rename on. * @return true if the item can be renamed, false otherwise. */ public boolean isItemRenamingAllowed(TreeItem<File> item) { boolean canRename = false; if (item != this.getRoot()) { final File configurationFile = new File(this.getEngine().getWorkingDirectory(), this.getEngine().getConfigurationFilename()); canRename = item != null && item.getValue() != null && !item.getValue().equals(configurationFile); } return canRename; } /** * Tests if the given item is allowed to be deleted. The root and the configuration item can not be deleted. * * @param item The item to test the deletion on. * @return true if the item can be deleted, false otherwise. */ public boolean isItemDeletionEnabled(TreeItem<File> item) { boolean canDelete = false; if (item != null && item != this.getRoot()) { final File configurationFile = new File(this.getEngine().getWorkingDirectory(), this.getEngine().getConfigurationFilename()); canDelete = !item.getValue().equals(configurationFile); } return canDelete; } private void removeCustomPseudoClass(Node node) { node.pseudoClassStateChanged(VALID_DRAG, false); node.pseudoClassStateChanged(INVALID_DRAG, false); } public EventHandler<DragEvent> getOnDragOverItem() { return onDragOverItem; } public EventHandler<DragEvent> getOnDragDroppedItem() { return onDragDroppedItem; } public EventHandler<DragEvent> getOnDragDoneItem() { return onDragDoneItem; } public EventHandler<DragEvent> getOnDragExitedItem() { return onDragExitedItem; } /** * Get the property containing the template engine associated to this TreeView. * * @return The property containing the engine. */ public ObjectProperty<TemplateEngine> engineProperty() { return this.engine; } /** * Return the template engine associated to this TreeView. * * @return the template engine associated to this TreeView. */ public TemplateEngine getEngine() { return this.engineProperty().get(); } /** * Set the template engine associated to this TreeView. * * @param engine the new engine associated to this TreeView */ public void setEngine(TemplateEngine engine) { this.engineProperty().set(engine); } }