Most Efficient Way to Log Messages to Javafx Textarea via Threads with Simple Custom Logging Frameworks

Most efficient way to log messages to JavaFX TextArea via threads with simple custom logging frameworks

log-view.css

.root {
-fx-padding: 10px;
}

.log-view .list-cell {
-fx-background-color: null; // removes alternating list gray cells.
}

.log-view .list-cell:debug {
-fx-text-fill: gray;
}

.log-view .list-cell:info {
-fx-text-fill: green;
}

.log-view .list-cell:warn {
-fx-text-fill: purple;
}

.log-view .list-cell:error {
-fx-text-fill: red;
}

LogViewer.java

import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.util.Duration;

import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Date;
import java.util.Random;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;

class Log {
private static final int MAX_LOG_ENTRIES = 1_000_000;

private final BlockingDeque<LogRecord> log = new LinkedBlockingDeque<>(MAX_LOG_ENTRIES);

public void drainTo(Collection<? super LogRecord> collection) {
log.drainTo(collection);
}

public void offer(LogRecord record) {
log.offer(record);
}
}

class Logger {
private final Log log;
private final String context;

public Logger(Log log, String context) {
this.log = log;
this.context = context;
}

public void log(LogRecord record) {
log.offer(record);
}

public void debug(String msg) {
log(new LogRecord(Level.DEBUG, context, msg));
}

public void info(String msg) {
log(new LogRecord(Level.INFO, context, msg));
}

public void warn(String msg) {
log(new LogRecord(Level.WARN, context, msg));
}

public void error(String msg) {
log(new LogRecord(Level.ERROR, context, msg));
}

public Log getLog() {
return log;
}
}

enum Level { DEBUG, INFO, WARN, ERROR }

class LogRecord {
private Date timestamp;
private Level level;
private String context;
private String message;

public LogRecord(Level level, String context, String message) {
this.timestamp = new Date();
this.level = level;
this.context = context;
this.message = message;
}

public Date getTimestamp() {
return timestamp;
}

public Level getLevel() {
return level;
}

public String getContext() {
return context;
}

public String getMessage() {
return message;
}
}

class LogView extends ListView<LogRecord> {
private static final int MAX_ENTRIES = 10_000;

private final static PseudoClass debug = PseudoClass.getPseudoClass("debug");
private final static PseudoClass info = PseudoClass.getPseudoClass("info");
private final static PseudoClass warn = PseudoClass.getPseudoClass("warn");
private final static PseudoClass error = PseudoClass.getPseudoClass("error");

private final static SimpleDateFormat timestampFormatter = new SimpleDateFormat("HH:mm:ss.SSS");

private final BooleanProperty showTimestamp = new SimpleBooleanProperty(false);
private final ObjectProperty<Level> filterLevel = new SimpleObjectProperty<>(null);
private final BooleanProperty tail = new SimpleBooleanProperty(false);
private final BooleanProperty paused = new SimpleBooleanProperty(false);
private final DoubleProperty refreshRate = new SimpleDoubleProperty(60);

private final ObservableList<LogRecord> logItems = FXCollections.observableArrayList();

public BooleanProperty showTimeStampProperty() {
return showTimestamp;
}

public ObjectProperty<Level> filterLevelProperty() {
return filterLevel;
}

public BooleanProperty tailProperty() {
return tail;
}

public BooleanProperty pausedProperty() {
return paused;
}

public DoubleProperty refreshRateProperty() {
return refreshRate;
}

public LogView(Logger logger) {
getStyleClass().add("log-view");

Timeline logTransfer = new Timeline(
new KeyFrame(
Duration.seconds(1),
event -> {
logger.getLog().drainTo(logItems);

if (logItems.size() > MAX_ENTRIES) {
logItems.remove(0, logItems.size() - MAX_ENTRIES);
}

if (tail.get()) {
scrollTo(logItems.size());
}
}
)
);
logTransfer.setCycleCount(Timeline.INDEFINITE);
logTransfer.rateProperty().bind(refreshRateProperty());

this.pausedProperty().addListener((observable, oldValue, newValue) -> {
if (newValue && logTransfer.getStatus() == Animation.Status.RUNNING) {
logTransfer.pause();
}

if (!newValue && logTransfer.getStatus() == Animation.Status.PAUSED && getParent() != null) {
logTransfer.play();
}
});

this.parentProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
logTransfer.pause();
} else {
if (!paused.get()) {
logTransfer.play();
}
}
});

filterLevel.addListener((observable, oldValue, newValue) -> {
setItems(
new FilteredList<LogRecord>(
logItems,
logRecord ->
logRecord.getLevel().ordinal() >=
filterLevel.get().ordinal()
)
);
});
filterLevel.set(Level.DEBUG);

setCellFactory(param -> new ListCell<LogRecord>() {
{
showTimestamp.addListener(observable -> updateItem(this.getItem(), this.isEmpty()));
}

@Override
protected void updateItem(LogRecord item, boolean empty) {
super.updateItem(item, empty);

pseudoClassStateChanged(debug, false);
pseudoClassStateChanged(info, false);
pseudoClassStateChanged(warn, false);
pseudoClassStateChanged(error, false);

if (item == null || empty) {
setText(null);
return;
}

String context =
(item.getContext() == null)
? ""
: item.getContext() + " ";

if (showTimestamp.get()) {
String timestamp =
(item.getTimestamp() == null)
? ""
: timestampFormatter.format(item.getTimestamp()) + " ";
setText(timestamp + context + item.getMessage());
} else {
setText(context + item.getMessage());
}

switch (item.getLevel()) {
case DEBUG:
pseudoClassStateChanged(debug, true);
break;

case INFO:
pseudoClassStateChanged(info, true);
break;

case WARN:
pseudoClassStateChanged(warn, true);
break;

case ERROR:
pseudoClassStateChanged(error, true);
break;
}
}
});
}
}

class Lorem {
private static final String[] IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque hendrerit imperdiet mi quis convallis. Pellentesque fringilla imperdiet libero, quis hendrerit lacus mollis et. Maecenas porttitor id urna id mollis. Suspendisse potenti. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Cras lacus tellus, semper hendrerit arcu quis, auctor suscipit ipsum. Vestibulum venenatis ante et nulla commodo, ac ultricies purus fringilla. Aliquam lectus urna, commodo eu quam a, dapibus bibendum nisl. Aliquam blandit a nibh tincidunt aliquam. In tellus lorem, rhoncus eu magna id, ullamcorper dictum tellus. Curabitur luctus, justo a sodales gravida, purus sem iaculis est, eu ornare turpis urna vitae dolor. Nulla facilisi. Proin mattis dignissim diam, id pellentesque sem bibendum sed. Donec venenatis dolor neque, ut luctus odio elementum eget. Nunc sed orci ligula. Aliquam erat volutpat.".split(" ");
private static final int MSG_WORDS = 8;
private int idx = 0;

private Random random = new Random(42);

synchronized public String nextString() {
int end = Math.min(idx + MSG_WORDS, IPSUM.length);

StringBuilder result = new StringBuilder();
for (int i = idx; i < end; i++) {
result.append(IPSUM[i]).append(" ");
}

idx += MSG_WORDS;
idx = idx % IPSUM.length;

return result.toString();
}

synchronized public Level nextLevel() {
double v = random.nextDouble();

if (v < 0.8) {
return Level.DEBUG;
}

if (v < 0.95) {
return Level.INFO;
}

if (v < 0.985) {
return Level.WARN;
}

return Level.ERROR;
}

}

public class LogViewer extends Application {
private final Random random = new Random(42);

@Override
public void start(Stage stage) throws Exception {
Lorem lorem = new Lorem();
Log log = new Log();
Logger logger = new Logger(log, "main");

logger.info("Hello");
logger.warn("Don't pick up alien hitchhickers");

for (int x = 0; x < 20; x++) {
Thread generatorThread = new Thread(
() -> {
for (;;) {
logger.log(
new LogRecord(
lorem.nextLevel(),
Thread.currentThread().getName(),
lorem.nextString()
)
);

try {
Thread.sleep(random.nextInt(1_000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
},
"log-gen-" + x
);
generatorThread.setDaemon(true);
generatorThread.start();
}

LogView logView = new LogView(logger);
logView.setPrefWidth(400);

ChoiceBox<Level> filterLevel = new ChoiceBox<>(
FXCollections.observableArrayList(
Level.values()
)
);
filterLevel.getSelectionModel().select(Level.DEBUG);
logView.filterLevelProperty().bind(
filterLevel.getSelectionModel().selectedItemProperty()
);

ToggleButton showTimestamp = new ToggleButton("Show Timestamp");
logView.showTimeStampProperty().bind(showTimestamp.selectedProperty());

ToggleButton tail = new ToggleButton("Tail");
logView.tailProperty().bind(tail.selectedProperty());

ToggleButton pause = new ToggleButton("Pause");
logView.pausedProperty().bind(pause.selectedProperty());

Slider rate = new Slider(0.1, 60, 60);
logView.refreshRateProperty().bind(rate.valueProperty());
Label rateLabel = new Label();
rateLabel.textProperty().bind(Bindings.format("Update: %.2f fps", rate.valueProperty()));
rateLabel.setStyle("-fx-font-family: monospace;");
VBox rateLayout = new VBox(rate, rateLabel);
rateLayout.setAlignment(Pos.CENTER);

HBox controls = new HBox(
10,
filterLevel,
showTimestamp,
tail,
pause,
rateLayout
);
controls.setMinHeight(HBox.USE_PREF_SIZE);

VBox layout = new VBox(
10,
controls,
logView
);
VBox.setVgrow(logView, Priority.ALWAYS);

Scene scene = new Scene(layout);
scene.getStylesheets().add(
this.getClass().getResource("log-view.css").toExternalForm()
);
stage.setScene(scene);
stage.show();
}

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

The section below on selectable text is supplemental to the solution posted above. If you don't need selectable text, you can ignore the selection below.

Is it possible to make the text selectable?

There a few different options:

  1. It is a ListView, so you could use a multiple selection model, ensuring the CSS is configured to appropriately style the selected rows as you wish. That will do a row by row selection, not a straight text selection. You can add a listener to the selected items in the selection model and do appropriate processing when that changes.
  2. You could use a factory for the ListView which sets each cell to an appropriately styles read-only text field. That would allow somebody to select just a portion of text within a row rather than a whole row. But they wouldn't be able to select text across multiple rows in one go.
  • Copiable Label/TextField/LabeledText in JavaFX

  1. Rather than a ListView, you could base the implementation on a third-party read-only RichTextFX control, which would allow selection of text across multiple rows.

Try implementing the text selection approach which is appropriate for you and, if you can't get it to work, create a new question specific to selectable text logs, with a mcve.

JavaFX: Console Log in a TextArea + Multithreading and Tasks

I think root of the problem is one of the below;

  • System.out.println("text") is being a synchronized method.
  • accesing ui component outside of Ui thread

When you call System.out.println("text") from ui thread, the synchronization on System.out will cause UI to freeze for duration of synchronization.
You can check if this is the cause like below;(You have to wrap all your System.out calls like below, for only to test if the above theory is correct)

This will cause println methods to synchronize in different thread.(common-pool threads)

CompletableFuture.runAsync(()->System.out.println("text"));

You should also update output component in ui thread.(Problem is solved with this in this case)

    // or create new runnable if you are not using java8
Platform.runLater(()->output.appendText(String.valueOf((char) i)));

JavaFX - Automate Textarea purging (rolling policy?)

As I mention in the comments section of the question, I recommend you use a ListView instead of a TextArea. This gives you a few benefits:

  1. ListView is a "virtual" control—it only renders enough cells to fill the visible space and the cells are reused while scrolling. This allows one to have thousands of items in the ListView without rendering performance suffering.

  2. The model of a ListView is an observable list, which is a much better way to represent separate messages than having one giant String in a TextArea. When adding an element to the list causes it to grow beyond some arbitrary capacity you can simply remove an item(s) from the start of said list (or end, if inserting items at the top rather than the bottom).

  3. A ListView provides much greater flexibility when it comes to displaying your message. This is accomplished with a custom cell factory. For instance, you could have certain ranges of the message be different colors by using a TextFlow as the graphic of the ListCell. Make sure you read the documentation of Cell.updateItem(Object,boolean), however, as you have to override that method correctly; failing to do so can lead to artifacts due to the fact cells are reused.

A simple example:

import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import javafx.animation.PauseTransition;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.ListView;
import javafx.stage.Stage;
import javafx.util.Duration;

public class Main extends Application {

private static void generateMessages(Consumer<String> onNewMessage) {
AtomicInteger counter = new AtomicInteger();
PauseTransition pt = new PauseTransition(Duration.seconds(1));
pt.setOnFinished(e -> {
onNewMessage.accept(String.format("Message #%,d", counter.incrementAndGet()));
pt.playFromStart();
});
pt.playFromStart();
}

@Override
public void start(Stage primaryStage) {
ListView<String> listView = new ListView<>();
primaryStage.setScene(new Scene(listView, 500, 300));
primaryStage.show();

generateMessages(message -> {
listView.getItems().add(message);
if (listView.getItems().size() > 10) {
listView.getItems().remove(0);
}
});
}

}

Write logger message to file and textarea while maintaining default behaviour in Java

In your case, since you are using JDK default logging, your option is to write your own java.util.Handler and implement the publish method. Somewhat like this:

public class TextAreaHandler extends java.util.logging.Handler {

private JTextArea textArea = new JTextArea(50, 50);

@Override
public void publish(final LogRecord record) {
SwingUtilities.invokeLater(new Runnable() {

@Override
public void run() {
StringWriter text = new StringWriter();
PrintWriter out = new PrintWriter(text);
out.println(textArea.getText());
out.printf("[%s] [Thread-%d]: %s.%s -> %s", record.getLevel(),
record.getThreadID(), record.getSourceClassName(),
record.getSourceMethodName(), record.getMessage());
textArea.setText(text.toString());
}

});
}

public JTextArea getTextArea() {
return this.textArea;
}

//...
}

Then, you can get the text area from your handler in your Swing application, somewhat like:

for(Handler handler: logger.getHandlers()){
if(handler instanceof TextAreaHandler){
TextAreaHandler textAreaHandler = (TextAreaHandler) handler;
getContentPane().add(textAreaHandler.getTextArea());
}
}

Then, you make sure your logging.properties file contains the configuration of your new handler:

hackers.logging.TestDrive.level=INFO
hackers.logging.TestDrive.handlers=hackers.logging.TextAreaHandler

And, if you are not going to put this configuration in your default logging.properties file (located in your JRE lib folder) then make sure to provide the path to your customized logging.properties file in a property at application startup:

java -Djava.util.logging.config.file=my-customized-logging.properties ...

How do I update the input of a TextArea in real-time?

Here is a simple application, which has the functionality of a real-time command-line binding with a text-area.

  • Enter command on input(tree, time, etc) TextField and hit Enter key
  • The results will be appended in the text-area constantly

Demo

public class CommandLineTextAreaDemo extends Application {

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

@Override
public void start(Stage primaryStage) {
BorderPane root = new BorderPane();
root.setCenter(getContent());
primaryStage.setScene(new Scene(root, 200, 200));
primaryStage.show();
}

private BorderPane getContent() {
TextArea textArea = new TextArea();
TextField input = new TextField();

input.setOnAction(event -> executeTask(textArea, input.getText()));

BorderPane content = new BorderPane();
content.setTop(input);
content.setCenter(textArea);
return content;
}

private void executeTask(TextArea textArea, String command) {
Task<String> commandLineTask = new Task<String>() {
@Override
protected String call() throws Exception {
StringBuilder commandResult = new StringBuilder();
try {
Process p = Runtime.getRuntime().exec(command);
BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
commandResult.append(line + "\n");
}
} catch (IOException e) {
e.printStackTrace();
}
return commandResult.toString();
}
};

commandLineTask.setOnSucceeded(event -> textArea.appendText(commandLineTask.getValue()));

new Thread(commandLineTask).start();
}
}

If you want to use TextArea independently(without using input TextField), you can do something like this instead.

textArea.setOnKeyReleased(event -> {
if(event.getCode() == KeyCode.ENTER) {
String[] lines = textArea.getText().split("\n");
executeTask(textArea, lines[lines.length - 1]);
}
});

Logs out of order in TextArea

You start a Task on a different Thread to post the Runnables using Platform.runLater. The Runnables are executed in the order in which they are posted, however by posting them from a new Thread you loose control over the order in which they are submitted.

I'm not really sure why you use a different thread here anyways. Just post it directly from the current thread. The code that could be "expensive" runs there anyways:

@Override
public void append(final LoggingEvent loggingEvent) {
// create a copy to make sure it's not overwritten somewhere
final TextArea target = textArea;

if (target != null) {
final String message = this.layout.format(loggingEvent);
Platform.runLater(() -> {
target.selectEnd();
target.appendText(message);
});
}
}


Related Topics



Leave a reply



Submit