Lemur File Picker - review

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

image

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();
    }

}
3 Likes

Well, BitmapText has a LineWrapMode you can use in such cases. (NoWrap mode is what you are looking for I believe)

But from a quick glance, it seems Lemur Label and TextComponent do not expose a method for setting that.

Edit:
By the way, this seems related:

It’s probably better to ask the font but that won’t know border. If button has had its preferred size specifically set then it will no longer calculate it. Things like this have no perfect solution, though. Asking the font is probably a good starting place.

NoWrap (even if exposed) would not really help completely because you couldn’t do the cool (and IMO necessary) “…” thing.

Even in Swing this one is a tricky one and comes down to iterative layout… so a loop testing desired length against actual length is not so silly. Swing would be doing the same thing just through a giant tree of GUI components.

In fact, the normal button styling would only be applied if there were “.button” (note the dot) somewhere in the element ID. ie: “foo.button.bar” should pick up some automatic button styling but “foobutton.bar” or “foo.buttonbar” would not.

If it’s not a ‘button’ then, no. I mean, you are using a Button but that doesn’t conceptually make it a ‘button’. For example, ListBox’s default style for elements is “.item” even though it’s using Buttons. .item might be appropriate here, also.

Could be. I have wanted one plenty of times.

This sort of thing can kind of explode, though… wonder if maybe it wants its own subproject like the property panel. I think it might be fine for proto.

It’s interesting that you picked the hardest file view to implement (and my least favorite). I guess without folder trees it’s probably a good starting place… and avoids having to include a bunch of other file details, draggable column sizes… hmmm… maybe you have done the easiest view. :slight_smile:

Neat stuff, though.

I do not know why this is happening. I believe I’ve seen a similar issue with some of my popup windows resizing one frame after being shown. (Which may be a related but separate issue.)

I suspect some ordering issue between updateLogicalState(), when the GuiControl is updated, and when the changes get displayed.

If you find any clues about what’s happening then let me know.

Well yes, the bitmap font file must contain the character ‘…’ to be able to render it. Or it can be changed to something else using BitmapText.setEllipsisChar().

But LineWrapMode.Clip will work out of the box.

public class TestLineWrapMode extends SimpleApplication {

    public static void main(String[] args) {
        TestLineWrapMode app = new TestLineWrapMode();
        app.start();
    }

    public TestLineWrapMode() {
        super(new PopupState());
    }

    @Override
    public void simpleInitApp() {

        GuiGlobals.initialize(this);
        GuiGlobals globals = GuiGlobals.getInstance();
        BaseStyles.loadGlassStyle();
        Styles styles = globals.getStyles();
        styles.setDefaultStyle("glass");

        // We'll wrap the text in a window to make sure the layout is working
        Container window = new Container();

        // Create a label with some really long text
        String s = "This is an example of long text that should be word-wrapped if it"
                + " exceeds a certain maximum width.  Once it exceeds that width then"
                + " it should grow down and the layout should function appropriately."
                + " If it's working correctly, that is.";
        Label label = window.addChild(new Label(s));
        label.setMaxWidth(200);
        label.getBitmapText().setLineWrapMode(LineWrapMode.Clip);
        //label.getBitmapText().setEllipsisChar((char)0x2026);// '...'

        // Position the window and pop it up
        window.setLocalTranslation(100, 400, 100);
        guiNode.attachChild(window);
    }

}

Edit:
Or as a workaround, we can modify BitmapText so when ellipsis char is ‘…’ it inserts 3 dots instead.

I have found a bit more about why its happening (at least the first “why”, not “why the why”).

What you’ve mentioned correlates very closely to what I’ve found; both the GridPanel and BorderLayout seem to place everything on top of each other on the first tick and then on the second tick lay everything out correctly¹; leading to this flicker effect. I have updated the FilePicker to be much less “rebuild the world” (Previously it was detaching the GridPanel and creating a new one on scroll) and it is much less noticeable now, with minor flicker on folder change but not on scroll which was the most irritating. I consider that minor flicker acceptable so I’m going to continue tidying and testing then submit it for PR

¹ I wonder if this is caused by components being added in another components updateLogicalState so they end up not getting their own updateLogicalState until a tick later?

edit: pr submitted File picker by richardTingle · Pull Request #112 · jMonkeyEngine-Contributions/Lemur · GitHub

1 Like