diff --git a/extensions/LemurProto/src/main/java/com/simsilica/lemur/FilePicker.java b/extensions/LemurProto/src/main/java/com/simsilica/lemur/FilePicker.java new file mode 100644 index 00000000..9b5db231 --- /dev/null +++ b/extensions/LemurProto/src/main/java/com/simsilica/lemur/FilePicker.java @@ -0,0 +1,457 @@ +/* + * $Id$ + * + * Copyright (c) 2022, Simsilica, LLC + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.simsilica.lemur; + +import com.jme3.math.Vector3f; +import com.simsilica.lemur.component.BorderLayout; +import com.simsilica.lemur.component.BoxLayout; +import com.simsilica.lemur.core.GuiControl; +import com.simsilica.lemur.core.VersionedHolder; +import com.simsilica.lemur.core.VersionedObject; +import com.simsilica.lemur.core.VersionedReference; +import com.simsilica.lemur.grid.ArrayGridModel; +import com.simsilica.lemur.style.ElementId; +import com.simsilica.lemur.style.Styles; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * A component that provides a grid style file picker + * + * |==========================================================| + * | C:/ > users > my documents | + * |==========================================================| + * | folder1/ folder2/ folder3/ |▲| + * | folder4/ someFile.txt someFile2.doc | | + * | someFile2.exe |▼| + * |========================================================== + * + * The component will automatically navigate based on clicking items in the folder path at the top (navigate upwards), + * clicking folder items in the content area or programatically in response to calls to {@link this#setCurrentPath}. + * + * Clicking on a file will trigger {@link this#selectedFileModel} (Obtainable by a call to {@link this#getSelectedFileModel()} + * to be updated but will not visually do anything in response to a file click. This is intentional to allow this + * component to be as general as possible. + * + * This component might be used as PART of a component that in response to a click on a file field might open as an + * overlay and might auto-close in response to a click on a file, but it does not do that itself. + * + * The size of the picker can be configured as can the sort order of the files. The files (and folders) can also be + * filtered (E.g. if you only want to allow the user to select .png files) + * + * @author Richard Tingle + */ +public class FilePicker extends Panel{ + public static final Predicate IS_A_DIRECTORY = Files::isDirectory; + public static final Predicate IS_A_FILE = p -> !Files.isDirectory(p); + + private static final String PATH_ITEM_ID = "path.item"; + + /** + * In the main contents of the picker this is appended to folders + */ + private static final String FILE_SEPARATOR = System.getProperty("file.separator"); + + /** + * In the top bar of the file picker this is what separates folders + */ + private static final String FOLDER_LOCATION_SEPARATOR = " > "; + + public static final ElementId ELEMENT_ID = new ElementId("filePicker"); + + public static final String VALUE_ID = "value"; + + /** + * The folder that is being looked at + */ + private final VersionedHolder currentPathModel = new VersionedHolder<>(null); + + private final VersionedReference currentPathRef = currentPathModel.createReference(); + + /** + * Standardise the folders to be able to hold at least this number of characters (thinner + * character may be able to display more) + */ + private final VersionedHolder fileCharacterWidthModel = new VersionedHolder<>(15); + + /** + * How many characters (approximately) should be displayable in the file content area before being clipped + */ + private final VersionedReference fileCharacterWidthRef = fileCharacterWidthModel.createReference(); + + private final VersionedHolder> fileSortOrder = new VersionedHolder<>(null); + private final VersionedReference> fileSortOrderRef = fileSortOrder.createReference(); + + private final VersionedHolder> fileAndFolderFilter = new VersionedHolder<>(IS_A_DIRECTORY.or(IS_A_FILE)); + private final VersionedReference> fileAndFolderFilterRef = fileAndFolderFilter.createReference(); + + /** + * A boolean just to keep track of the very first time the item lays itself out + */ + private boolean initialised = false; + + /** + * The actual file selected (if any) + */ + VersionedHolder selectedFileModel = new VersionedHolder<>(null); + + RangedValueModel sliderModel = new DefaultRangedValueModel(0,5, 0); + private final Slider slider; + + private final VersionedHolder noOfColumnsModel = new VersionedHolder<>(4); + private final VersionedReference noOfColumnsReference = noOfColumnsModel.createReference(); + + private final VersionedHolder noOfRowsToDisplayModel = new VersionedHolder<>(5); + private final VersionedReference noOfRowsToDisplayModelReference = noOfRowsToDisplayModel.createReference(); + + BorderLayout layout = new BorderLayout(); + private final Container currentLocationFolders = new Container(); + private GridPanel folderContentsContainer; + + public FilePicker( Path startingPath, int noOfColumns, int noOfRowsToDisplay ){ + this(startingPath, noOfColumns, noOfRowsToDisplay, ELEMENT_ID, null); + } + + public FilePicker( Path startingPath, int noOfColumns, int noOfRowsToDisplay, String style ){ + this(startingPath, noOfColumns, noOfRowsToDisplay, ELEMENT_ID, style); + } + + public FilePicker( Path startingPath, int noOfColumns, int noOfRowsToDisplay, ElementId elementId, String style ){ + super(elementId, style); + noOfColumnsModel.setObject(noOfColumns); + noOfRowsToDisplayModel.setObject(noOfRowsToDisplay); + getControl(GuiControl.class).setLayout(layout); + + currentLocationFolders.setLayout(new BoxLayout(Axis.X, FillMode.None)); + + layout.addChild(BorderLayout.Position.North, currentLocationFolders ); + + slider = new Slider(Axis.Y, elementId.child("slider"), style); + slider.setModel(sliderModel); + layout.addChild(BorderLayout.Position.East, slider); + + Comparator fileSortOrderComparator = Comparator.comparing(p -> !Files.isDirectory(p)); + fileSortOrderComparator = fileSortOrderComparator.thenComparing(p -> p.getFileName() == null? "": p.getFileName().toString().toLowerCase()); + fileSortOrder.setObject(fileSortOrderComparator); + + setCurrentPath(startingPath); + } + + /** + * The path to a folder that the file picker should start at. User clicks may move the file picker to a + * different location. To track that call {@link FilePicker#getCurrentPathModel} + */ + public void setCurrentPath( Path path ){ + this.currentPathModel.setObject(path); + } + + /** + * The model for the folder that the file picker is currently looking at (which may be changed by user action). + * To monitor when this changes call {@link VersionedObject#createReference()} and poll it's + * {@link VersionedReference#update()} method + */ + public VersionedObject getCurrentPathModel(){ + return currentPathModel; + } + + /** + * The model for the file that the file picker has most recently selected (if any). When a new location is navigated + * to this selectedFile is returned to null + * To monitor when this changes call {@link VersionedObject#createReference()} and poll it's + * {@link VersionedReference#update()} method + */ + public VersionedObject getSelectedFileModel(){ + return selectedFileModel; + } + + /** + * Sets the number of columns rendered in the picker + */ + public void setNumberOfColumns( int numberOfColumns ){ + this.noOfColumnsModel.setObject(numberOfColumns); + } + + /** + * Sets the number of rows rendered in the picker (a scroll bar allows further rows to be viewed). + */ + public void setNumberOfRowsToDisplay( int numberOfRows ){ + this.noOfRowsToDisplayModel.setObject(numberOfRows); + } + + /** + * Sets the maximum length that a file name should be before it is clipped. This is a worst case scenario + * (a word made up only of long letters like MW etc) so more characters may be displayed if possible + */ + public void setMaximumCharactersInFileNameToDisplay( int characters ){ + this.fileCharacterWidthModel.setObject(characters); + } + + /** + * Sets the order that the folders and files will be shown in. Default is directories then files with a + * case-insensitive sort within those groups + */ + public void setFileAndFolderSortOrder( Comparator sortOrder ){ + this.fileSortOrder.setObject(sortOrder); + } + + /** + * Filters the files and folders that will be offered to the user. + * + * There are convenience methods/variables available {@link FilePicker#IS_A_FILE} {@link FilePicker#IS_A_DIRECTORY} + * and {@link FilePicker#hasFileExtension} that can make common filters easier to write + * + * Note that this is for FOLDERS AS WELL. So if you wanted to show all image file types (but also wanted to show + * folders) you'd pass: + * + * FilePicker.IS_A_DIRECTORY.or(FilePicker.hasExtension(".png", ".jpg", ".jpeg")) + */ + public void setFileAndFolderFilter( Predicate filter ){ + this.fileAndFolderFilter.setObject(filter); + } + + @Override + public void updateLogicalState( float tpf ) { + //these must be non short-circuiting ORs to ensure that all updates are batched together, rather than pointlessly re-updating + boolean update = !initialised + | this.fileCharacterWidthRef.update() + | this.noOfColumnsReference.update() + | this.noOfRowsToDisplayModelReference.update() + | this.fileSortOrderRef.update() + | this.fileAndFolderFilterRef.update() + | this.currentPathRef.update(); + + if (update){ + rebuildContentsArea(); + /* although it may feel like the location bar probably can be not refreshed under some of these update + * scenarios in practice it needs to because options like noOfColumns can adjust how much space the location + * bar has */ + updateLocationBar(); + } + + updateGridWindowView(); //the grid is smart enough to ignore updates to the same value so can run this every tick for scroll changes + this.initialised = true; + } + + private void updateLocationBar(){ + this.selectedFileModel.setObject(null); + + currentLocationFolders.getLayout().clearChildren(); + + float maxAvailableWidth = folderContentsContainer.getPreferredSize().x; + Path pathPart = this.currentPathRef.get(); + + //proceeds up the stack, creating buttons for each folder as it goes + List folderButtons = new ArrayList<>(); + while( pathPart != null ){ + //the C:/ bit doesn't report as a "Filename", so special case that + String text = pathPart.getFileName() == null ? pathPart.toString() : pathPart.getFileName().toString(); + + Button buttonToJumpToLevel = new Button( text, getElementId().child( PATH_ITEM_ID ), getStyle() ); + folderButtons.add(buttonToJumpToLevel); + Path pathPart_final = pathPart; + buttonToJumpToLevel.addClickCommands(source -> setCurrentPath(pathPart_final)); + pathPart = pathPart.getParent(); + } + //reverse the buttons so they are in the natural order. Starting at high level folder and getting more specific + Collections.reverse(folderButtons); + + Button clippedPathIndicator = new Button( "...", getElementId().child( PATH_ITEM_ID ), getStyle() ); + float dividerWidth = new Label(FOLDER_LOCATION_SEPARATOR).getPreferredSize().x; + + int pathIndex = folderButtons.size()-1; + //keep trying more and more items until more won't all fit (or we have added everything) + while( pathIndex==0 + || (pathIndex>0 && currentLocationFolders.getPreferredSize().x + folderButtons.get(pathIndex-1).getPreferredSize().x + dividerWidth * 3 < maxAvailableWidth )){ + currentLocationFolders.getLayout().clearChildren(); + + if (pathIndex != 0){ + currentLocationFolders.addChild(clippedPathIndicator); + currentLocationFolders.addChild(new Label(FOLDER_LOCATION_SEPARATOR)); + } + + for(int i = pathIndex; i itemsInFolder; + try{ + itemsInFolder = Files.list(this.currentPathRef.get()) + .filter(this.fileAndFolderFilter.getObject()) + .sorted(this.fileSortOrder.getObject()) + .collect(Collectors.toList()); + } catch (AccessDeniedException e){ + itemsInFolder = List.of(); + } catch(IOException e){ + throw new RuntimeException(e); + } + + int noOfRows = Math.max(noOfRowsToDisplayModelReference.get(), itemsInFolder.size()/noOfColumns+1); + + ArrayGridModel folderContentsModel = new ArrayGridModel<>(new Panel[noOfRows][noOfColumnsReference.get()]); + + Vector3f buttonStandardSize = buildAndMeasureStandardButtonSize(); + + int rowIndex = 0; + int columnIndex = 0; + + if (itemsInFolder.isEmpty()){ + Label emptyLabel = new Label("[Empty]"); + emptyLabel.setPreferredSize(buttonStandardSize); + folderContentsModel.setCell(0,0,emptyLabel); + columnIndex++; + }else{ + for(Path item:itemsInFolder){ + boolean isDirectory = Files.isDirectory(item); + + String text = item.getFileName().toString() + (isDirectory ? FILE_SEPARATOR : ""); + Button buttonFolderContents = fitButtonToWidth(text, getElementId().child( "content.item" ), buttonStandardSize); + + buttonFolderContents.addClickCommands(source -> { + if (isDirectory){ + setCurrentPath(item); + }else{ + selectedFileModel.setObject(item); + } + }); + folderContentsModel.setCell(rowIndex,columnIndex,buttonFolderContents); + columnIndex++; + if (columnIndex>=noOfColumns){ + rowIndex++; + columnIndex = 0; + } + } + } + + //fill out the empty space with placeholders to ensure consistent spacing + for(;rowIndex3 && button.getPreferredSize().x>buttonStandardSize.x){ + text = text.substring(0, text.length()-2); + button.setText(text + "..."); + } + button.setPreferredSize(buttonStandardSize); + return button; + } + + private Vector3f buildAndMeasureStandardButtonSize(){ + //M is one of the widest characters, use that to measure a theoretical max sized button + Button buttonToJumpToLevel = new Button( "M".repeat(fileCharacterWidthRef.get()), getElementId().child( PATH_ITEM_ID ), getStyle() ); + Styles styles = GuiGlobals.getInstance().getStyles(); + styles.applyStyles( buttonToJumpToLevel, getElementId(), getStyle()); + return buttonToJumpToLevel.getPreferredSize(); + } + + /** + * Convenience method for {@link FilePicker#setFileAndFolderFilter} + * + * This method with generate a predicate for files with the requested extension. + * + * Note folders will not pass the test so should be paired with {@link FilePicker#IS_A_DIRECTORY} + */ + public static Predicate hasFileExtension( String... extensions ){ + return p -> { + String fullPath = p.toString(); + + for(String extension : extensions){ + if (fullPath.endsWith(extension)){ + return true; + } + } + return false; + }; + } + +}