2

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?

Taha
  • 1,592
  • 2
  • 18
  • 24
  • One thought would be to filter the changes of the text area's text property to remove changes that are simply the addition of a newline character. (And then just merge the two streams.) Unfortunately, I don't see a particularly easy way to implement that filter. – James_D Sep 09 '16 at 02:57
  • @James_D I want to keep newline characters (Shift+Enter event). Actually the problem is (in `suspend` version) the text change event that fires after I call `suspend()`. I think this happens because the whole _suspend->search->resume_ thing happens during the one second. – Taha Sep 09 '16 at 03:07
  • I meant filter them from the event stream, not filter them from the text area. See answer. Not tested though. – James_D Sep 09 '16 at 03:09

3 Answers3

3

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

}
Tomas Mikula
  • 6,537
  • 25
  • 39
  • This works as expected. I added another event stream for enter presses tho. One with shift pressed (insert newline at caret), and the other one without shift (should trigger immediate search). – Taha Sep 09 '16 at 03:57
  • But there's a tiny problem, it fires on load. Is there a way to ignore that initial null event? – Taha Sep 09 '16 at 04:03
  • Also, an RP question, is consuming the `KeyEvent` considered a "side effect"? If so, wouldn't using `hook` be more appropriate here than using `map`? – Taha Sep 09 '16 at 04:05
  • @UltimateZero I updated the code to ignore the initial null event. You are right about `hook`, I made the change in the code. – Tomas Mikula Sep 09 '16 at 04:41
2

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

}
Tomas Mikula
  • 6,537
  • 25
  • 39
0

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));
}
James_D
  • 201,275
  • 16
  • 291
  • 322