FXMisc / RichTextFX

Rich-text area for JavaFX
BSD 2-Clause "Simplified" License
1.21k stars 236 forks source link

Question: how to limit the number of input characters to the visible area? #832

Closed HoneyWTFBBQ closed 5 years ago

HoneyWTFBBQ commented 5 years ago

Hello, I am new to RichTextFX. I would like to restrict user input to the visible area of the InlineCssTextArea either by setting the number of maximum characters or rows in the textarea. The visible area of my InlineCssTextArea is only two rows but RichTextFX will let the user input a wall of text. Here is my code:

    public void start(Stage primaryStage) {
        HBox layout = new HBox();
        InlineCssTextArea textArea = new InlineCssTextArea();
        textArea.setPrefWidth(225);
        textArea.setPrefHeight(35);
        textArea.setWrapText(true);
        textArea.setBackground(new Background(new BackgroundFill(Paint.valueOf("#004c34"), CornerRadii.EMPTY, Insets.EMPTY)));
        String description = "Marked task Buy Milk complete";
        textArea.appendText(description);
        textArea.setStyle(0, "Marked task ".length(), "-fx-fill: #ffffff;");
        textArea.setStyle(description.indexOf("Marked task ") + "Marked task ".length(), description.lastIndexOf("complete"), "-fx-font-weight: bold; -fx-fill: #008000;");
        textArea.setStyle(description.lastIndexOf("complete"), description.length(), "-fx-fill: #ffffff;");     
        layout.getChildren().addAll(textArea);
    Scene scene = new Scene(layout);
    primaryStage.setScene(scene);
    primaryStage.show();
    }

I combed through the RichTextFX API and searched on Google. I found issue #763 which I was able to use.

Adding the following code gets me what I want but I don't have any undo history. If I don't clear the undo history, then previously deleted characters might get undone. If the forgetHistory line is moved into the IF-statement above it, then there appears to be a race condition between textArea.undo() and forget undo history. For example, given a string of 30 characters. Delete 5 characters using the backspace or delete key. Immediately press an alphanumeric key and the deleted characters become undone.

        InputMap<Event> map = InputMap.consume(anyOf(keyPressed(ENTER, SHORTCUT_ANY, SHIFT_ANY)));
        Nodes.addInputMap(textArea, map);

        textArea.setOnKeyReleased(event -> {
            if (textArea.getLength() > 25) {
                if ( (!event.getCode().equals(KeyCode.BACK_SPACE)) &&
                        (!event.getCode().equals(KeyCode.DELETE)) ) {
                    textArea.undo();
                }
            }
            textArea.getUndoManager().forgetHistory();
        });

Does this make sense? Or am I missing something? Are there any plans to support setting a limit on the text length? Thanks for your time.

Jugen commented 5 years ago

Try extending InlineCssTextArea with something like the following and see if that works for you:

public class LimitedTextArea extends InlineCssTextArea
{
    private int maxLen = 0; // Default, no limit

    public void setMaxLength( int numOfChars )
    {
        maxLen = numOfChars;
    }

    @Override
    public void replaceText( int start, int end, String text )
    {
        super.replaceText( start, end, checkText( text, end - start ) );
    }

    protected String checkText( String text, int replaceLen )
    {
        if ( maxLen < 1 ) return text; // Default, no limit
        int length = getLength() - replaceLen;

        if ( text.length() > 1 ) {  // This is for when text is pasted ....
            if ( length + text.length() > maxLen ) {
                text = text.substring( 0,  maxLen - length );
            }
        }
        else if ( length >= maxLen ) return ""; // and this for typing.

        return text;
    }
}
Jugen commented 5 years ago

There was a bug in the previous code, which I've edited and fixed now.

HoneyWTFBBQ commented 5 years ago

Thanks for the quick response. Your solution works great! I had not accounted for pasted text. But now I also have to change my FXML from using a InlineCssTextArea to a LimitedTextArea and SceneBuilder complains that it doesn't know what a LimitedTextArea is. How do I workaround this?

Jugen commented 5 years ago

You will have to package LimitedTextArea.class into a JAR and then import that JAR into SceneBuilder, just like you did with RichTextFX.jar

Note that if you want to be able to set the maximum characters in SceneBuilder/FXML then you will also need to add a getMaxLength() method to LimitedTextArea.

HoneyWTFBBQ commented 5 years ago

That worked! Actually I searched for "richtextfx" by repository in SceneBuilder. There are two matches: "com.github.alexp11223:richtextfx" is the one that works. "org.fxmisc.richtext:richtextfx" imports the JAR but no UI components.

When the text in a LimitedTextArea is edited and committed, I don't know how to update the model. My LimitedTextArea is part of a ListCell and multiple ListCells make up a ListView. My ListView is initialized in a ListViewController. My CustomCell class is a separate class where events for the LimitedTextArea are handled in an overriden updateItem method. The ObservableList for items in the ListView is in the controller but the event handlers for LimitedTextArea are in the CustomCell class. How can I update the ObservableList when the key/mouse event is handled in CustomCell? I've disabled hitting the ENTER key to keep the text within the visible area. So I thought maybe when the ListCell loses focus, then that will be the signal to commit and save changes. But I still can't figure out how the two classes can communicate to achieve this. Any ideas would be appreciated.

Jugen commented 5 years ago

Ok, so I'm going to assume that disabling the ENTER key is what the problem is here. The "hacky" fix would be as you suggest to have a focusListener on the LimitedTextArea in your CustomCell where you have something like:

T item = getItem();
item.setFoo( textarea.getText() );
commitEdit( item );

But I think the better way of doing it would be to add the above code to the section where you disabled the ENTER key, then you won't need the focus listener. Of course this also depends on the behavior you / user want or expect.

HoneyWTFBBQ commented 5 years ago

I think I understand now. I disabled the Enter key but I can still listen for when the Enter key is pressed. Then I get the item and set the text like you said and it works!

    @Override
    protected void updateItem(Task task, boolean empty) {
        TaskComponent tc = new TaskComponent();
        LimitedTextArea textArea = (LimitedTextArea) tc.getDescription();
        super.updateItem(task, empty);

        if(empty || task == null) {
            setText(null);
        }
        else {
            // snipped
            InputMap<Event> map = InputMap.consume(anyOf(keyPressed(ENTER, SHORTCUT_ANY, SHIFT_ANY)));
            Nodes.addInputMap(tc.getDescription(), map);

            textArea.setOnKeyPressed(event -> {
            if (event.getCode() == KeyCode.ENTER) {
                    Task item = getItem();
                item.setDescription(textArea.getText());
            }
            });
        }
    }

Since my ListView supports drag-and-drop, I also added a mouse dragged listener to commit the edit instead of a focus listener. Otherwise, if the user drags and drops a cell that was just edited, the edited text would revert to it's original text.

I'm sorry for asking so many questions. One last issue I'm having is, when I double-click the LimitedTextArea to edit the text, all of the text is selected which is what I want. But when I edit a different LimitedTextArea, the text in the previous LimitedTextArea is not deselected.

    this.setOnMouseClicked(event -> {
        if (event.getClickCount() == 2) {
            textArea.setEditable(true);
            textArea.selectAll();
        }
        else {
            textArea.setEditable(false);
        }
    });

The event handler only knows about the current textarea, right? So is it possible to know which textarea was previously edited so I can deselect its text?

Jugen commented 5 years ago

No problem on asking questions :-) I hope you don't mind but I'm going to address a larger concern than just to answer your question, which should also then fix your problem in the end.

Ok, so from your code it appears as though you are using RichTextFX to both view and edit in your ListView. That's not a problem but what has happened as a natural result is that you've bypassed / taken over ListView's workflow so to speak and landed up here.

First a short lesson, and forgive me if you already know this. The normal way ListCell's work is that they have a display component (a Label) and an edit component (e.g. TextField) and the ListView tells the ListCell to switch between these as appropriate.

So we start with: ListView -> ListCell.updateItem -> setText("Note: updateItem can be called many times and so shouldn't have initialization things like textArea.setOnKeyPressed in it");

Then if a ListView is editable and you click on a selected item it calls ListCell.startEdit which is where you setup and show the editing component. Usually this is done by hiding the Label's text and calling setGraphic( editor ) to display the editing component. In your case the editing component is already displayed so all you should do is call textarea.setEditable( true ); textarea.selectAll(); and textArea.requestFocus();

After this the user can either cancel the edit (ESC or click away) or commit the edit (ENTER or click away). This behavior you determine when initializing the editor component, just make whatever click away behavior you choose consistent across your entire application.

When you detect a commit then you call ListCell.commitEdit which you override to update the model and restore the ListCell to display mode. In the case of a cancel you call ListCell.cancelEdit which you also override to restore the ListCell to display mode. It's important that you do this because ListView can call cancelEdit when the focus changes.

If we put all the above together then we should get something like the following:

public class CustomCell extends ListCell<Task>
{
    private LimitedTextArea area = new LimitedTextArea();
    private StyledDocument<String,String,String> styledDoc;

    public CustomCell( int maxLength )
    {
        area.setMaxLength( maxLength );
        area.addEventFilter( KeyEvent.KEY_PRESSED, this::processKey );
        area.focusedProperty().addListener( this::processFocus );
        area.setMouseTransparent( true );
        area.setEditable( false );

        setContentDisplay( ContentDisplay.GRAPHIC_ONLY );
        setEditable( true );
        setGraphic( area );
    }

    private void processKey( KeyEvent KE )
    {
        if ( KE.getCode() == KeyCode.ENTER )
        {
            commitEdit( getItem() );
            KE.consume();
        }
        else if ( KE.getCode() == KeyCode.ESCAPE )
        {
            cancelEdit();
            KE.consume();
        }
    }

    private void processFocus( ObservableValue<? extends Boolean> ob, Boolean was, Boolean is )
    {
        if ( was && ! is ) commitEdit( getItem() );
    }

    @Override public void updateItem( Task item, boolean empty )
    {
        if ( item == null || empty ) area.clear();
        else area.replaceText( item.getDescription() );
        super.updateItem( item, empty );    
    }

    @Override public void startEdit()
    {
        styledDoc = ReadOnlyStyledDocument.from( area.getContent() );
        area.setMouseTransparent( false );
        area.setEditable( true );
        area.requestFocus();
        area.selectAll();
    }

    @Override public void commitEdit( Task item )
    {
        item.setDescription( area.getText() );
        super.commitEdit( item );
        stopEdit();
    }

    @Override public void cancelEdit()
    {
        area.replace( styledDoc );
        super.cancelEdit();
        stopEdit();
    }

    private void stopEdit()
    {
        area.setMouseTransparent( true );
        area.setEditable( false );
        area.deselect();
        styledDoc = null;
        getListView().requestFocus();
    }
}
HoneyWTFBBQ commented 5 years ago

Thank you very much! I appreciate the detailed explanation. Your code is much clearer and concise. As you said, my problem with deselecting text in a cell that is no longer being edited is now resolved. Although, when a key is pressed, the "processKey" method isn't getting called. But if I change "area" to the reserved word "this", then it works as expected.

this.addEventFilter( KeyEvent.KEY_PRESSED, this::processKey );

I believe this has to do with the LimitedTextArea being inside of a ListCell because I tested using a LimitedTextArea without a ListView and area.addEventFilter() works fine. Unfortunately, that would mean if there are multiple LimitedTextAreas in a ListCell, then there's no way to know which LimitedTextArea the user is trying to edit, right?

Also, is there a way to get the style information before calling area.replaceText( getItem().getDescription() );? Currently, when cancelEdit is called, the existing text loses all of it's style information.

Jugen commented 5 years ago

First the easy question: You can get the styled contents with area.getContent() which is a live model, so we need to make a fixed copy otherwise it'll reflect any changes made. So put the following code in startEdit():

styledDoc = ReadOnlyStyledDocument.from( area.getContent() );

Then in cancelEdit() instead of replaceText use: area.replace( styledDoc ); and in stopEdit() put styledDoc = null;

Now the hard question: If you had multiple LimitedTextAreas in a ListCell then you would need to use some trickery to know which area the user wants to edit. This part is easy, but getting multiple text areas working successfully inside a ListCell is not easy and has problems. Only do this if there is no other way and you'll have to tinker at it on your own to get all the kinks out. I can almost guarantee you that it'll be frustrating !

So usually you would get the text area being edited when the user clicks on it but since we've made the text area's transparent to mouse events that's not going to work. So you'll need to add another event filter to the ListCell constructor to process mouse events, like so: addEventFilter( MouseEvent.ANY, this::processMouse ); Then in the processMouse method we manually search for the right text area based on the mouse coordinates whenever there's a mouse press:

private void processMouse( MouseEvent ME )
{
    if ( ME.getEventType() == MouseEvent.MOUSE_PRESSED )
    {
        Pane parent = (Pane) getGraphic(); // container of the text areas ?
        Optional<Node> newEd = parent.getChildren().stream()
        .filter( node -> node instanceof LimitedTextArea )
        .filter( n -> n.getBoundsInParent().contains( ME.getX(), ME.getY() ) )
        .findAny();

        if ( newEd.isPresent() )
        {
            if ( isBusyEditing && newEd.get() != editor ) commitEdit( getItem() );
            editor = (LimitedTextArea) newEd.get();
        }
    }
    else if ( ME.getEventType() == MouseEvent.MOUSE_CLICKED )
    {
        // ListView doesn't like multi consecutive editing so we manually do it ?
        if ( ! isBusyEditing && ME.getClickCount() > 1 ) startEdit();
    }
}

Note that isBusyEditing is a private field varible. Add isBusyEditing = true; to startEdit() and isBusyEditing = false; to stopEdit() and lastly add it to the condition in processFocus.

Then you'll need to change updateItem and commitEdit to get and set from your model correctly. One way of doing that for commit is to use case statements with switch( area.getUserData().toString() ) or with if ( area.getUserData() == SOME_TAG ) where you've previously tagged each area with setUserData( SOME_TAG ) during initialization. The tags should be private static final String fields.

Hope this works out for you, if I haven't been clear on something or you want more info on some detail then please ask.

Jugen commented 5 years ago

I've updated the processMouse code in the previous post. Also note that getListView().requestFocus(); in stopEdit() must move to the end of the method.

HoneyWTFBBQ commented 5 years ago

I can confirm using the "styledDoc" like you said preserves the styles when editing is canceled. I think I have most of it working now. I've attached the code for your review. TaskCell.zip

I had to debug a couple of minor issues. Now, a NullPointerException could occur from trying to edit a Label. I added if ( editor != null ) around the switch statement in startEdit() to resolve that. Also, editing a textarea and then clicking on a label would enable edit mode on the previous textarea. This happens because clicking on a textarea set the value of the "editor" variable but clicking on a label afterwards did not reset its value. I added editor = null; in stopEdit() to resolve that.

Not sure if this is a JavaFX issue instead of RichTextFX issue but I would like to align the text in a textarea to the right. I searched and found issue #74 but textArea.setStyle( 0, textArea.getText().length(), "-fx-text-alignment: right;"); isn't aligning the text to the right.

textArea.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); makes the initial text appear right-aligned but editing the text behaves strangely. When the textarea is in edit mode, the whitespace in front of the text is selected instead of the entire text. When the textarea is initialized with some text, the caret cannot be placed within that text.

Jugen commented 5 years ago

Well done with the debugging :-) I had a look at TaskCell and would very much like to see TaskComponent as well if you don't mind. There are two things I'd recommend at the moment:

The first is to move setNodeOrientation from updateItem into either the constructor or into TaskComponent.

The second is to look at how CodeArea highlights keywords (instead of using InlineCssTextArea) and maybe consider using that technique. See the JavaKeywordsDemo code.

I'll have a look at the RIGHT_TO_LEFT behavior as that seems like a bug. Please open a new issue in that regard in the meantime. Hope you have a great weekend ....

Jugen commented 5 years ago

To align the text in a textarea to the right you need to use setParagraphStyle instead of setStyle, so try: textArea.setParagraphStyle( 0, "-fx-text-alignment: right;" );

HoneyWTFBBQ commented 5 years ago
public class TaskComponent {
    @FXML
    private Pane taskCellPane;

    @FXML
    private ImageView image;

    @FXML
    private LimitedTextArea description;

    @FXML
    private Label timestamp;

    @FXML
    private LimitedTextArea owner;

    public TaskComponent() {
        FXMLLoader fxmlLoader = new FXMLLoader(TaskApplication.class.getResource("/fxml/Task.fxml"));
        fxmlLoader.setController(this);
        try
        {
            fxmlLoader.load();
        }
        catch (IOException e)
        {
            throw new RuntimeException(e);
        }
    }

    public Pane getTaskCellPane() {
        return taskCellPane;
    }

    public ImageView getImage() {
        return image;
    }

    public LimitedTextArea getDescription() {
        return description;
    }

    public Label getTimestamp() {
        return timestamp;
    }

    public LimitedTextArea getOwner() {
        return owner;
    }

}
HoneyWTFBBQ commented 5 years ago

I looked at the JavaKeywordsDemo code, as you suggested. I tried to incorporate some of that into my code but ran into some roadblocks. First, CodeArea is an extension of StyledTextArea<Collection, Collection> while InlineCssTextArea extends StyledTextArea<String,String>. If I change my LimitedTextArea to extend CodeArea instead of InlineCssTextArea, then that breaks my TaskCell code. But if I don't, then I cannot call setStyleSpans

The method setStyleSpans(int, StyleSpans<? extends String>) in the type GenericStyledArea<String,String,String> is not applicable for the arguments (int, StyleSpans<Collection>)

I temporarily changed LimitedTextArea to extend CodeArea so I could push forward with my experiment. It highlights the defined keywords but for some reason, I cannot get it to highlight the words between the keywords.

    private static final String MARKED_TASK_TEXT_PATTERN = Pattern.quote("Marked task") + "(.*?)" + Pattern.quote("complete");
    private static final Pattern PATTERN = Pattern.compile(
             "|(?<MARKEDTASK>" + MARKED_TASK_TEXT_PATTERN + ")"
    );

    private static StyleSpans<Collection<String>> computeHighlighting(String text) {
        while(matcher.find()) {
            System.out.println(matcher.group("MARKEDTASK")); // returns null

It's a cool feature but I spent too much time today trying to get it to work. Thanks for the suggestion though.

HoneyWTFBBQ commented 5 years ago

I'm closing this issue since my original question was answered. Thanks for going above and beyond my expectations.