I have created a lemur file picker that I indend to contribute to lemur-proto. It broadly works but I want to get a bit of guidance before finishing it off.
It looks like this
As in a normal file picker clicking on the folders at the top allows moving up the folder structure, clicking on a folder moves into that folder. Clicking on a file updates a model that that file has been updated (so other components can care about that) but doesn’t automatically do anything visual so that this picker can be as general as possible.
Outstanding questions
Clipping (file names)
My approach to file name clipping is to just keep editing a button’s text and asking it how long it is until is is short enough. This is for things like this
private Button fitButtonToWidth( String text, ElementId elementId, float desiredWidth){
Button button = new Button( text,elementId );
Styles styles = GuiGlobals.getInstance().getStyles();
styles.applyStyles( button, getElementId(), getStyle());
while(button.getPreferredSize().x>desiredWidth){
text = text.substring(0, text.length()-2);
button.setText(text + "...");
}
return button;
}
Is there a better way to tell a button to clip text rather than wrapping it?
Approach to IDs
I’ve been using child style IDs. For the slider like:
slider = new Slider( Axis.Y, getElementId().child("slider"), style);
For the “buttons” that are files and folders like:
getElementId().child( "folderItemButton" )
This led to the button’s styling disappearing, which initially surprised me, but was kind of exactly what I wanted (I think because the buttons terminal ID is not “button”). Are there any gotchas to doing this?
Flicker on update
I’ve been doing my updates in updateLogicalState
following the example of the colorChooser which I think is fairly analogous to this and GridPanel, but whenever an update happens it briefly flickers. Looking at a video frame by frame it briefly looks like this:
With everything rendered on top of each other. Any idea why this is happening and what I can do to stop it?
Is this actually wanted
Is this component sufficiently general that it makes sense to have it in lemur-proto?
Full source
import com.jme3.math.Vector3f;
import com.simsilica.lemur.Axis;
import com.simsilica.lemur.Button;
import com.simsilica.lemur.Container;
import com.simsilica.lemur.DefaultRangedValueModel;
import com.simsilica.lemur.FillMode;
import com.simsilica.lemur.GridPanel;
import com.simsilica.lemur.GuiGlobals;
import com.simsilica.lemur.Label;
import com.simsilica.lemur.Panel;
import com.simsilica.lemur.RangedValueModel;
import com.simsilica.lemur.Slider;
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.stream.Collectors;
public class FilePicker extends Panel{
/**
* 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<Path> currentPathModel = new VersionedHolder<>(null);
/**
* The file that has been selected (if any)
*/
private final VersionedReference<Path> 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<Integer> fileCharacterWidthModel = new VersionedHolder<>(15);
/**
* The file that has been selected (if any)
*/
private final VersionedReference<Integer> fileCharacterWidthRef = fileCharacterWidthModel.createReference();
/**
* The actual file selected
*/
VersionedHolder<Path> selectedFileModel = new VersionedHolder<>(null);
RangedValueModel sliderModel = new DefaultRangedValueModel(0,5, 0);
private final Slider slider;
private final VersionedHolder<Integer> noOfColumnsModel = new VersionedHolder<>(4);
private final VersionedReference<Integer> noOfColumnsReference = noOfColumnsModel.createReference();
private final VersionedHolder<Integer> noOfRowsToDisplayModel = new VersionedHolder<>(5);
private final VersionedReference<Integer> noOfRowsToDisplayModelReference = noOfRowsToDisplayModel.createReference();
BorderLayout layout = new BorderLayout();
private final Container currentLocationFolders = new Container();
private GridPanel folderContentsContainer;
private int sliderLocation = -1;
public FilePicker(Path startingPath, int noOfColumns){
this(startingPath, noOfColumns, ELEMENT_ID, null);
}
public FilePicker(Path startingPath, int noOfColumns, String style){
this(startingPath, noOfColumns, ELEMENT_ID, style);
}
public FilePicker(Path startingPath, int noOfColumns, ElementId elementId, String style){
super(elementId, style);
noOfColumnsModel.setObject(noOfColumns);
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);
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);
this.selectedFileModel.setObject(null);
sliderLocation = -1; //this indicates that it should be the maximum slider value, which isn't yet known
}
/**
* 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()}
*/
public VersionedObject<Path> 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()}
*/
public VersionedHolder<Path> 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 );
}
@Override
public void updateLogicalState( float tpf ) {
//because the slider only matters when integer different we have to handle this specially
int newSlide = (int)this.sliderModel.getValue();
boolean updateContents =
this.fileCharacterWidthRef.update()
| sliderLocation != newSlide
| this.noOfColumnsReference.update()
| this.noOfRowsToDisplayModelReference.update();
boolean updateCurrentLocation = false;
if (this.currentPathRef.update()){
updateContents = true;
updateCurrentLocation = true;
}
if (updateContents){
updateFolderContents();
}
if (updateCurrentLocation){
updateCurrentLocation();
}
}
/**
* Called when the current path changes, leading to the top bar re-rendering
*/
private void updateCurrentLocation(){
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<Panel> 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( "pathButton" ) );
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( "pathButton" ) );
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<folderButtons.size();i++){
currentLocationFolders.addChild(folderButtons.get(i));
if (i<folderButtons.size()-1){
currentLocationFolders.addChild(new Label(FOLDER_LOCATION_SEPARATOR));
}
}
pathIndex--;
}
}
/**
* Called when the current path changes, leading to the top bar re-rendering
*/
private void updateFolderContents(){
if (folderContentsContainer != null ){
layout.removeChild(folderContentsContainer);
}
Comparator<Path> comparator = Comparator.comparing(p -> !Files.isDirectory(p));
comparator = comparator.thenComparing(p -> p.getFileName() == null? "": p.getFileName().toString().toLowerCase());
int noOfColumns = noOfColumnsReference.get();
List<Path> itemsInFolder;
try{
itemsInFolder = Files.list(this.currentPathRef.get())
.sorted(comparator)
.collect(Collectors.toList());
} catch (AccessDeniedException e){
itemsInFolder = List.of();
} catch(IOException e){
throw new RuntimeException(e);
}
int noOfRows = itemsInFolder.size()/noOfColumns+1;
int noOfRowsSkippable = Math.max(0,noOfRows-noOfRowsToDisplayModelReference.get());
sliderModel.setMaximum(noOfRowsSkippable);
slider.setDelta(1);
if (sliderLocation == -1){
sliderModel.setValue(sliderModel.getMaximum());
}
sliderLocation = (int)sliderModel.getValue();
//slider at the top is the maximum value, but for a folder scroll bar we think of it as the minimum
int rowsToSkip = (int)(sliderModel.getMaximum() - sliderLocation);
Panel[][] items = new Label[noOfRows][noOfColumns];
Vector3f buttonStandardSize = buildAndMeasureStandardButtonSize();
List<Path> itemsToDisplay = itemsInFolder.stream().skip( (long)rowsToSkip * noOfColumns ).collect(Collectors.toList());
if (itemsToDisplay.isEmpty()){
Label emptyLabel = new Label("[Empty]");
emptyLabel.setPreferredSize(buttonStandardSize);
items[0][0] = emptyLabel;
}else{
int rowIndex = 0;
int columnIndex = 0;
for(Path item:itemsToDisplay){
boolean isDirectory = Files.isDirectory(item);
String text = item.getFileName().toString() + (isDirectory?FILE_SEPARATOR:"");
Button buttonFolderContents = fitButtonToWidth(text, getElementId().child( "folderItemButton" ), buttonStandardSize.x);
buttonFolderContents.setPreferredSize(buttonStandardSize);
buttonFolderContents.addClickCommands(source -> {
if (isDirectory){
setCurrentPath(item);
}else{
selectedFileModel.setObject(item);
}
});
items[rowIndex][columnIndex] = buttonFolderContents;
columnIndex++;
if (columnIndex>=noOfColumns){
rowIndex++;
columnIndex = 0;
}
}
}
ArrayGridModel<Panel> model = new ArrayGridModel<>(items);
folderContentsContainer = new GridPanel(model);
folderContentsContainer.setVisibleSize(noOfRowsToDisplayModelReference.get(), noOfColumns);
layout.addChild(BorderLayout.Position.Center, folderContentsContainer );
}
private Button fitButtonToWidth( String text, ElementId elementId, float desiredWidth){
Button button = new Button( text,elementId );
Styles styles = GuiGlobals.getInstance().getStyles();
styles.applyStyles( button, getElementId(), getStyle());
while(button.getPreferredSize().x>desiredWidth){
text = text.substring(0, text.length()-2);
button.setText(text + "...");
}
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( "pathButton" ) );
Styles styles = GuiGlobals.getInstance().getStyles();
styles.applyStyles( buttonToJumpToLevel, getElementId(), getStyle());
return buttonToJumpToLevel.getPreferredSize();
}
}