ReactFX - "lazy" real-time search text area

280 Views Asked by At

New to Reactive Programming here.

I'm trying to implement a "lazy" real-time search text area in JavaFX with ReactFX. By lazy here I mean it performs the search once the user stops typing for one second. The code for that was pretty simple:

EventStream<Change<String>> textEvents = EventStreams.changesOf(textArea.textProperty())
.successionEnds(Duration.ofSeconds(1));

Then subscribe to that event stream and voilà.

But I also want it to perform the search instantly if the user presses Enter. I'm not sure how to do that in a "reactive" way. Simply performing the search on Enter key events causes the search to fire twice (one for key event and one for text change), so this is my current solution:

BooleanProperty hasSearched = new SimpleBooleanProperty(false);
EventStream<KeyEvent> enterKeyPressedEvents = EventStreams.eventsOf(textArea, KeyEvent.KEY_PRESSED)
        .filter(k -> k.getCode() == KeyCode.ENTER);
AwaitingEventStream<Change<String>> textEvents = EventStreams.changesOf(textArea.textProperty())
        .successionEnds(Duration.ofSeconds(1));

subs = Subscription.multi(
        //Text changed
        textEvents.subscribe(e -> {
            if (hasSearched.get()) {
                hasSearched.set(false);
                System.out.println("ignored text event");
            } else {
                performSearch(textArea.getText());
            }
        }),

        //Enter key pressed
        enterKeyPressedEvents.subscribe(e -> {
            e.consume();
            if (e.isShiftDown()) {
                textArea.insertText(textArea.getCaretPosition(), "\n");
            } else {
                hasSearched.set(true);
                System.out.println("enter pressed");
                performSearch(textArea.getText());
                if (!textEvents.isPending()) {
                    hasSearched.set(false);
                }
            }
        })
);

I've tried using SuspendableEventStream.suspend() thinking it would "drop" all pending events, but it didn't work as expected, the pending event is still emitted:

EventStream<KeyEvent> enterKeyPressedEvents = EventStreams.eventsOf(textArea, KeyEvent.KEY_PRESSED)
        .filter(k -> k.getCode() == KeyCode.ENTER);
SuspendableEventStream<Change<String>> textEvents = EventStreams.changesOf(textArea.textProperty())
        .successionEnds(Duration.ofSeconds(1)).suppressible();

subs = Subscription.multi(
        //Text changed
        textEvents.subscribe(e -> {
                performSearch(textArea.getText());
        }),

        //Enter key pressed
        enterKeyPressedEvents.subscribe(e -> {
            e.consume();
            if (e.isShiftDown()) {
                textArea.insertText(textArea.getCaretPosition(), "\n");
            } else {
                Guard guard = textEvents.suspend();
                System.out.println("enter pressed");
                performSearch(textArea.getText());
                guard.close();
            }
        })
);

How can I think of a better (more reactive?) solution?

3

There are 3 best solutions below

4
Tomas Mikula On BEST ANSWER

Here is a solution. The key part in this solution is observing text changes inside flatMap, which has the effect of "resetting" the stream of text changes.

import java.time.Duration;
import java.util.function.Function;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;

import org.reactfx.EventStream;
import org.reactfx.EventStreams;

public class AutoSearch extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws Exception {
        TextArea area = new TextArea();

        EventStream<KeyEvent> enterPresses = EventStreams.eventsOf(area, KeyEvent.KEY_PRESSED)
                .filter(k -> k.getCode() == KeyCode.ENTER)
                .hook(KeyEvent::consume);

        EventStream<?> searchImpulse = enterPresses.withDefaultEvent(null) // emit an event even before Enter is pressed
                .flatMap(x -> {
                    EventStream<?> edits = EventStreams.changesOf(area.textProperty())
                                                       .successionEnds(Duration.ofSeconds(1));
                    return ((x == null) ? edits : edits.withDefaultEvent(null))
                            .map(Function.identity()); // just to get the proper type of the result
                });

        searchImpulse.subscribe(x -> System.out.println("Search now!"));

        stage.setScene(new Scene(area));
        stage.show();
    }

}
0
James_D On

Not tested, but how about:

EventStream<KeyEvent> enterKeyPressedEvents = EventStreams.eventsOf(textArea, KeyEvent.KEY_PRESSED)
        .filter(k -> k.getCode() == KeyCode.ENTER);
EventStream<Change<String>> textEvents = EventStreams.changesOf(textArea.textProperty())
        .successionEnds(Duration.ofSeconds(1))
        .filter(c -> ! isAdditionOfNewline(c, textArea.getCaratPosition()));

EventStreams.merge(enterKeyPressedEvents, textEvents)
        .subscribe(o -> performSearch(textArea.getText()));

private boolean isAdditionOfNewline(Change<String> change, int caratPos) {
    // TODO make sure this works as required
    String oldText = change.getOldValue();
    String newText = change.getNewValue();
    if (oldText.length()+1 != newText.length() || caratPos == 0) {
        return false ;
    }

    return oldText.equals(newText.substring(0, caratPos-1) + newText.substring(caratPos));
}
0
Tomas Mikula On

Here is another solution. This one counts the Enter presses and only lets the edit event trigger the search if the Enter count hasn't changed in the meantime.

import java.time.Duration;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;

import org.reactfx.EventStream;
import org.reactfx.EventStreams;

public class AutoSearch2 extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws Exception {
        TextArea area = new TextArea();

        EventStream<KeyEvent> enterPressed = EventStreams.eventsOf(area, KeyEvent.KEY_PRESSED)
                .filter(k -> k.getCode() == KeyCode.ENTER)
                .hook(KeyEvent::consume);

        EventStream<Long> enterCount = enterPressed.accumulate(0L, (n, k) -> n + 1)
                                                   .withDefaultEvent(0L);

        EventStream<Long> delayedEdits = enterCount.emitOnEach(EventStreams.changesOf(area.textProperty()))
                                                   .successionEnds(Duration.ofSeconds(1));

        // check that the delayed edit event still has the current value of the Enter counter
        EventStream<?> validEdits = enterCount.emitBothOnEach(delayedEdits)
                                              .filter(cd -> cd.test((current, delayed) -> delayed == current));

        EventStream<?> searchImpulse = EventStreams.merge(enterPressed, validEdits);

        searchImpulse.subscribe(x -> System.out.println("Search now!"));

        stage.setScene(new Scene(area));
        stage.show();
    }

}