Skip to main content
Tweeted twitter.com/StackCodeReview/status/1555478723314450432
Add a funky pic.
Source Link
coderodde
  • 32.3k
  • 15
  • 79
  • 205

TextEditorApp

TextEditorApp

Source Link
coderodde
  • 32.3k
  • 15
  • 79
  • 205

Funny TextUIWindow.java and a simple colorful text editor

(See this repository for full code.)

Now I have rolled a simple class extending JavaFX Canvas for showing terminal like, colorful console:

TextUIWindow.java:

package com.github.coderodde.ui;

import javafx.scene.canvas.Canvas;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import com.sun.javafx.tk.FontMetrics;
import com.sun.javafx.tk.Toolkit;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import javafx.event.EventHandler;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.FontWeight;

/**
 * This class implements a simple colorful terminal window.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.6 (Jul 24, 2022)
 * @since 1.6 (Jul 24, 2022)
 */
public class TextUIWindow extends Canvas {

    private static final int MINIMUM_WIDTH = 1;
    private static final int MINIMUM_HEIGHT = 1;
    private static final int MINIMUM_FONT_SIZE = 1;
    private static final Color DEFAULT_TEXT_BACKGROUND_COLOR = Color.BLACK;
    private static final Color DEFAULT_TEXT_FOREGROUND_COLOR = Color.WHITE;
    private static final Color DEFAULT_BLINK_BACKGROUND_COLOR = Color.WHITE;
    private static final Color DEFAULT_BLINK_FOREGROUND_COLOR = Color.BLACK;
    private static final char DEFAULT_CHAR = ' ';
    private static final String FONT_NAME = "Monospaced";
    private static final int DEFAULT_CHAR_DELIMITER_LENGTH = 4;

    private final int width;
    private final int height;
    private final int fontSize;
    private final Font font;
    private final int fontCharWidth;
    private final int fontCharHeight;
    private final int charDelimiterLength;
    private int windowTitleBorderThickness;
    private final Set<TextUIWindowMouseListener> mouseMotionListeners = 
            new HashSet<>();

    private final Set<TextUIWindowKeyboardListener> keyboardListeners =
            new HashSet<>();

    private final Color[][] backgroundColorGrid;
    private final Color[][] foregroundColorGrid;
    private final boolean[][] cursorGrid;
    private final char[][] charGrid;
    private Color textBackgroundColor = DEFAULT_TEXT_BACKGROUND_COLOR;
    private Color textForegroundColor = DEFAULT_TEXT_FOREGROUND_COLOR;
    private Color blinkCursorBackgroundColor = DEFAULT_BLINK_BACKGROUND_COLOR;
    private Color blinkCursorForegroundColor = DEFAULT_BLINK_FOREGROUND_COLOR;

    public TextUIWindow(int width, int height, int fontSize) {
        this(width, height, fontSize, DEFAULT_CHAR_DELIMITER_LENGTH);
    }

    public TextUIWindow(int width, 
                        int height, 
                        int fontSize, 
                        int charDelimiterLength) {

        this.width = checkWidth(width);
        this.height = checkHeight(height);
        this.fontSize = checkFontSize(fontSize);
        this.charDelimiterLength = 
                checkCharDelimiterLength(charDelimiterLength);

        this.font = getFont();
        this.fontCharWidth = getFontWidth();
        this.fontCharHeight = getFontHeight();

        backgroundColorGrid = new Color[height][width];
        foregroundColorGrid = new Color[height][width];
        charGrid = new char[height][width];
        cursorGrid = new boolean[height][width];

        setDefaultForegroundColors();
        setDefaultBackgroundColors();
        setChars();

        this.setWidth(width * (fontCharWidth + charDelimiterLength));
        this.setHeight(height * fontCharHeight);
        this.setFocusTraversable(true);
        this.addEventFilter(MouseEvent.ANY, (e) -> this.requestFocus());

        setMouseListeners();
        setMouseMotionListeners();
        setKeyboardListeners();
    }

    public Color getTextForegroundColor() {
        return textForegroundColor;
    }

    public Color getTextBackgroundColor() {
        return textBackgroundColor;
    }

    public Color getBlinkCursorForegroundColor() {
        return blinkCursorForegroundColor;
    }

    public Color getBlinkCursorBackgroundColor() {
        return blinkCursorBackgroundColor;
    }

    public void setForegroundColor(Color color) {
        textForegroundColor = 
                Objects.requireNonNull(color, "The input color is null.");
    }

    public void setBackgroundColor(Color color) {
        textBackgroundColor = 
                Objects.requireNonNull(color, "The input color is null.");
    }

    public void turnOffBlink(int charX, int charY) {
        if (checkXandY(charX, charY)) {
            cursorGrid[charY][charX] = false;
        }
    }

    public void setBlinkCursorBackgroundColor(Color backgroundColor) {
        this.blinkCursorBackgroundColor =
                Objects.requireNonNull(
                        backgroundColor, 
                        "backgroundColor is null.");
    }

    public void setBlinkCursorForegroundColor(Color foregroundColor) {
        this.blinkCursorForegroundColor =
                Objects.requireNonNull(
                        foregroundColor, 
                        "foregroundColor is null.");
    }

    public void setTextBackgroundColor(Color backgroundColor) {
        this.textBackgroundColor =
                Objects.requireNonNull(backgroundColor, 
                                       "The input color is null.");
    }

    public void setTextForegroundColor(Color foregroundColor) {
        this.textForegroundColor =
                Objects.requireNonNull(foregroundColor, 
                                       "The input color is null.");
    }

    public int getGridWidth() {
        return width;
    }

    public int getGridHeight() {
        return height;
    }

    public void toggleBlinkCursor(int charX, int charY) {
        if (checkXandY(charX, charY)) {
            cursorGrid[charY][charX] = !cursorGrid[charY][charX];
        }
    }

    public boolean readCursorStatus(int charX, int charY) {
        if (!checkX(charX)) {
            throw charXToException(charX);
        }

        if (!checkY(charY)) {
            throw charYToException(charY);
        }

        return cursorGrid[charY][charX];
    }

    public void printString(int charX, int charY, String text) {
        if (!checkY(charY)) {
            return;
        }

        for (int i = 0; i < text.length(); ++i) {
            setChar(charX + i, charY, text.charAt(i));

            if (!checkX(charX + i)) {
                // Once here, the input text string proceeds beyond the right
                // border. Nothing to print, can exit.
                return;
            }
        }
    }

    public void addTextUIWindowMouseListener(
            TextUIWindowMouseListener listener) {
        mouseMotionListeners.add(listener);
    }

    public void removeTextUIWindowMouseListener(
            TextUIWindowMouseListener listener) {
        mouseMotionListeners.remove(listener);
    }

    public void addTextUIWindowKeyboardListener(
            TextUIWindowKeyboardListener listener) {
        keyboardListeners.add(listener);
    }

    public void removeTextUIWindowKeyboardListener(
            TextUIWindowKeyboardListener listener) {
        keyboardListeners.remove(listener);
    }

    private void setMouseListeners() {
        setMouseClickedListener();
        setMouseEnteredListener();
        setMousePressedListener();
        setMouseReleasedListener();
        setMouseExitedListener();
    }

    private void setMouseMotionListeners() {
        setMouseMovedListener();
        setMouseDraggedListener();
    }

    private void setKeyboardListeners() {
        setKeyboardPressedListener();
        setKeyboardReleaseListener();
        setKeyboardTypedListener();
    }

    private void setKeyboardPressedListener() {
        this.setOnKeyPressed(new EventHandler<KeyEvent>() {
            @Override
            public void handle(KeyEvent event) {
                for (TextUIWindowKeyboardListener listener 
                        : keyboardListeners) {
                    listener.onKeyPressed(event);
                }
            }
        });
    }

    private void setKeyboardReleaseListener() {
        this.setOnKeyReleased(new EventHandler<KeyEvent>() {
            @Override
            public void handle(KeyEvent event) {
                for (TextUIWindowKeyboardListener listener 
                        : keyboardListeners) {
                    listener.onKeyReleased(event);
                }
            }
        });
    }

    private void setKeyboardTypedListener() {
        this.addEventFilter(KeyEvent.KEY_TYPED, new EventHandler<KeyEvent>() {
            public void handle(KeyEvent event) {
                for (TextUIWindowKeyboardListener listener : keyboardListeners) {
                    listener.onKeyTyped(event);
                }
            }
        });
    }

    private void setMouseMovedListener() {
        this.setOnMouseMoved(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMouseMove(event, charX, charY);
                }
            }
        });
    }

    private void setMouseDraggedListener() {
        this.setOnMouseDragged(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMouseClick(event, charX, charY);
                }
            }
        });
    }

    private void setMouseClickedListener() {
        this.setOnMouseClicked(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMouseClick(event, charX, charY);
                }
            }
        });
    }

    private void setMouseEnteredListener() {
        this.setOnMouseEntered(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMouseEntered(event, charX, charY);
                }
            }
        });
    }

    private void setMouseExitedListener() {
        this.setOnMouseExited(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMouseExited(event, charX, charY);
                }
            }
        });
    }

    private void setMousePressedListener() {
        this.setOnMousePressed(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMousePressed(event, charX, charY);
                }
            }
        });
    }

    private void setMouseReleasedListener() {
        this.setOnMouseReleased(new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                int pixelX = (int) event.getX();
                int pixelY = (int) event.getY();

                int charX = convertPixelXtoCharX(pixelX);
                int charY = convertPixelYtoCharY(pixelY);

                for (TextUIWindowMouseListener listener 
                        : mouseMotionListeners) {
                    listener.onMouseReleased(event, charX, charY);
                }
            }
        });
    }

    private int convertPixelXtoCharX(int pixelX) {
        return pixelX / (fontCharWidth + charDelimiterLength);
    }

    private int convertPixelYtoCharY(int pixelY) {
        return (pixelY - windowTitleBorderThickness) / fontCharHeight;
    }

    public void setTitleBorderThickness(int thickness) {
        this.windowTitleBorderThickness = thickness;
    }

    public void repaint() {
        GraphicsContext gc = getGraphicsContext2D();

        for (int y = 0; y < height; y++) {
            repaintRow(gc, y);
        }
    }

    private void repaintRow(GraphicsContext gc, int y) {
        for (int x = 0; x < width; x++) {
            repaintCell(gc, x, y);
        }
    }

    private void repaintCell(GraphicsContext gc, int x, int y) {
        repaintCellBackground(gc, x, y);
        repaintCellForeground(gc, x, y);
    }

    private void repaintCellBackground(GraphicsContext gc,  
                                       int charX, 
                                       int charY) {
        if (cursorGrid[charY][charX]) {
            // Once here, we need to use the cursor's color:
            gc.setFill(blinkCursorBackgroundColor);
        } else {
            gc.setFill(backgroundColorGrid[charY][charX]);
        }

        gc.fillRect(charX * (fontCharWidth + charDelimiterLength),
                    charY * fontCharHeight,
                    fontCharWidth + charDelimiterLength,
                    fontCharHeight);
    }

    private void repaintCellForeground(GraphicsContext gc,
                                       int charX, 
                                       int charY) {
        gc.setFont(font);

        if (cursorGrid[charY][charX]) {
            gc.setFill(blinkCursorForegroundColor);
        } else {
            gc.setFill(foregroundColorGrid[charY][charX]);
        }

        int fixY = fontCharHeight - (int) getFontMetrics().getMaxAscent();

        gc.fillText("" + charGrid[charY][charX],
                    charDelimiterLength / 2 +
                            (fontCharWidth + charDelimiterLength) * charX,
                    fontCharHeight * (charY + 1) - fixY);
    }

    public Color getForegroundColor(int charX, int charY) {
        if (!checkX(charX)) {
            throw charXToException(charX);
        }

        if (!checkY(charY)) {
            throw charYToException(charY);
        }

        return foregroundColorGrid[charY][charX];
    }

    public Color getBackgroundColor(int charX, int charY) {
        if (!checkX(charX)) {
            throw charXToException(charX);
        }

        if (!checkY(charY)) {
            throw charYToException(charY);
        }

        return backgroundColorGrid[charY][charX];
    }

    public void setForegroundColor(int charX, int charY, Color color) {
        if (checkXandY(charX, charY)) {
            foregroundColorGrid[charY][charX] = 
                    Objects.requireNonNull(color, "The color is null.");
        }
    }

    public void setBackgroundColor(int x, int y, Color color) {
        if (checkXandY(x, y)) {
            backgroundColorGrid[y][x] = 
                    Objects.requireNonNull(color, "The color is null.");
        }
    }

    public char getChar(int charX, int charY) {
        if (!checkX(charX)) {
            throw charXToException(charX);
        }

        if (!checkY(charY)) {
            throw charYToException(charY);
        }

        return charGrid[charY][charX];
    }

    public void setChar(int x, int y, char ch) {
        if (checkXandY(x, y)) {
            charGrid[y][x] = ch;
            foregroundColorGrid[y][x] = textForegroundColor;
            backgroundColorGrid[y][x] = textBackgroundColor;
        }
    }

    public int getPreferredWidth() {
        return width * (fontCharWidth + charDelimiterLength);
    }

    public int getPreferredHeight() {
        return height * fontCharHeight;
    }

    private FontMetrics getFontMetrics() {
        return Toolkit.getToolkit().getFontLoader().getFontMetrics(font);
    }

    private static int checkWidth(int widthCandidate) {
        if (widthCandidate < MINIMUM_WIDTH) {
            throw new IllegalArgumentException(
                    "Width candidate is invalid (" 
                            + widthCandidate
                            + "). Must be at least " 
                            + MINIMUM_WIDTH
                            + ".");
        }

        return widthCandidate;
    }

    private static int checkHeight(int heightCandidate) {
        if (heightCandidate < MINIMUM_WIDTH) {
            throw new IllegalArgumentException(
                    "Height candidate is invalid (" 
                            + heightCandidate
                            + "). Must be at least " 
                            + MINIMUM_HEIGHT
                            + ".");
        }

        return heightCandidate;
    }

    private static int checkFontSize(int fontSizeCandidate) {
        if (fontSizeCandidate < MINIMUM_FONT_SIZE) {
            throw new IllegalArgumentException(
                    "Font size candidate is invalid (" 
                            + fontSizeCandidate
                            + "). Must be at least " 
                            + MINIMUM_FONT_SIZE
                            + ".");
        }

        return fontSizeCandidate;
    }

    private int checkCharDelimiterLength(int charDelimiterLength) {
        if (charDelimiterLength < 0) {
            throw new IllegalArgumentException(
                    "Char delimiter length negative: (" 
                            + charDelimiterLength 
                            + "). Must be at least 0.");
        }

        return charDelimiterLength;
    }

    private IndexOutOfBoundsException charXToException(int charX) {
        if (charX < 0) {
            return new IndexOutOfBoundsException(
                    "Character X coordinate is negative: " + charX);
        }

        if (charX >= width) {
            return new IndexOutOfBoundsException(
                    "Character X coordinate is too large: " 
                            + charX
                            + ". Must be at most "
                            + (width - 1)
                            + ".");
        }

        throw new IllegalStateException("Should not get here.");
    }

    private IndexOutOfBoundsException charYToException(int charY) {
        if (charY < 0) {
            throw new IndexOutOfBoundsException(
                    "Character Y coordinate is negative: " + charY);
        }

        if (charY >= height) {
            throw new IndexOutOfBoundsException(
                    "Character Y coordinate is too large: " 
                            + charY
                            + ". Must be at most "
                            + (height - 1)
                            + ".");
        }

        throw new IllegalStateException("Should not get here.");
    }

    private boolean checkX(int x) {
        return x >= 0 && x < width;
    }

    private boolean checkY(int y) {
        return y >= 0 && y < height;
    }

    private boolean checkXandY(int x, int y) {
        return checkX(x) && checkY(y);
    }

    private void setDefaultForegroundColors() {
        for (Color[] colors : foregroundColorGrid) {
            for (int i = 0; i < width; i++) {
                colors[i] = DEFAULT_TEXT_FOREGROUND_COLOR;
            }
        }
    }

    private void setDefaultBackgroundColors() {
        for (Color[] colors : backgroundColorGrid) {
            for (int i = 0; i < width; i++) {
                colors[i] = DEFAULT_TEXT_BACKGROUND_COLOR;
            }
        }
    }

    private void setChars() {
        for (char[] charRow : charGrid) {
            for (int i = 0; i < width; i++) {
                charRow[i] = DEFAULT_CHAR;
            }
        }
    }

    private Font getFont() {
        return Font.font(FONT_NAME, FontWeight.BOLD, fontSize);
    }

    private int getFontWidth() {
        return (int) getFontMetrics().getCharWidth('C') + charDelimiterLength;
    }

    private int getFontHeight() {
        return (int) getFontMetrics().getLineHeight();
    }
}

TextEditorApp.java:

package com.github.coderodde.ui;

import java.util.ArrayList;
import java.util.List;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.control.ColorPicker;
import javafx.scene.Scene;
import javafx.scene.SceneAntialiasing;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;

/**
 * This class implements a simple demo text editor.
 * 
 * @author Rodion "rodde" Efremov
 * @version 1.6 (Jul 14, 2022)
 * @since 1.6 (Jul 14, 2022)
 */
public class TextEditorApp extends Application {

    private static final int CHAR_GRID_WIDTH = 40;
    private static final int CHAR_GRID_HEIGHT = 24;
    private static final int FONT_SIZE = 17;
    private static final int CHAR_HORIZONTAL_DELIMITER_LENGTH = 1;
    private static final int SLEEP_MILLISECONDS = 400;
    private static final String HELLO_WORLD_STRING = "Hello, world! ";

    private final TextUIWindow window;
    private final HelloWorldThread helloWorldThread;
    private final CursorBlinkThread cursorBlinkThread;

    private volatile int cursorX = 0;
    private volatile int cursorY = 2;

    public TextEditorApp() {
        this.window = new TextUIWindow(CHAR_GRID_WIDTH,
                                       CHAR_GRID_HEIGHT,
                                       FONT_SIZE,
                                       CHAR_HORIZONTAL_DELIMITER_LENGTH);

        this.helloWorldThread = new HelloWorldThread();
        this.cursorBlinkThread = new CursorBlinkThread();
    }

    @Override
    public void stop() {
        helloWorldThread.requestExit();
        cursorBlinkThread.requestExit();

        try {
            helloWorldThread.join();
        } catch (InterruptedException ex) {

        }

        try {
            cursorBlinkThread.join();
        } catch (InterruptedException ex) {

        }
    }

    @Override
    public void start(Stage primaryStage) {
        Platform.runLater(() -> {

            try {
                ColorPicker textForegroundColorPicker = 
                        new ColorPicker(window.getTextForegroundColor());

                ColorPicker textBackgroundColorPicker = 
                        new ColorPicker(window.getTextBackgroundColor());

                ColorPicker cursorBlinkForegroundColorPicker = 
                        new ColorPicker(window.getBlinkCursorForegroundColor());

                ColorPicker cursorBlinkBackgroundColorPicker =
                        new ColorPicker(window.getBlinkCursorBackgroundColor());

                textForegroundColorPicker.setOnAction(new EventHandler() {
                    @Override
                    public void handle(Event t) {
                        Color color = textForegroundColorPicker.getValue();
                        window.setTextForegroundColor(color);
                        window.setForegroundColor(cursorX, cursorY, color);
                    }
                });

                textBackgroundColorPicker.setOnAction(new EventHandler() {
                    @Override
                    public void handle(Event t) {
                        Color color = textBackgroundColorPicker.getValue();
                        window.setTextBackgroundColor(color);
                        window.setBackgroundColor(cursorX, cursorY, color);
                    }
                });

                cursorBlinkForegroundColorPicker.setOnAction(new EventHandler() {
                    @Override
                    public void handle(Event t) {
                        window.setBlinkCursorForegroundColor(
                                cursorBlinkForegroundColorPicker.getValue());
                    }
                });

                cursorBlinkBackgroundColorPicker.setOnAction(new EventHandler() {
                    @Override
                    public void handle(Event t) {
                        window.setBlinkCursorBackgroundColor(
                                cursorBlinkBackgroundColorPicker.getValue());
                    }
                });

                HBox hboxColorPickers = new HBox(textForegroundColorPicker,
                                                 textBackgroundColorPicker,
                                                 cursorBlinkForegroundColorPicker,
                                                 cursorBlinkBackgroundColorPicker);

                VBox vbox = new VBox(hboxColorPickers, window);

                Scene scene = new Scene(vbox, 
                                        window.getPreferredWidth(), 
                                        window.getPreferredHeight() + 35, // How to get rid of this 35?
                                        false,
                                        SceneAntialiasing.BALANCED);

                window.setTitleBorderThickness((int) scene.getY());

                primaryStage.setScene(scene);
                window.addTextUIWindowMouseListener(new TextEditorMouseListener());
                window.addTextUIWindowKeyboardListener(
                        new TextEditorKeyboardListener());

                helloWorldThread.start();
                cursorBlinkThread.start();

                primaryStage.setResizable(false);
                primaryStage.show();
                window.requestFocus();
                window.repaint();
            } catch (Exception ex) {
                ex.printStackTrace();
                helloWorldThread.requestExit();
                cursorBlinkThread.requestExit();

                try {
                    helloWorldThread.join();
                } catch (InterruptedException ex2) {

                }

                try {
                    cursorBlinkThread.join();
                } catch (InterruptedException ex2) {

                }
            }
        });
    }

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

    private void moveCursorUp() {
        if (cursorY == 2) {
            return;
        }

        window.turnOffBlink(cursorX, cursorY);
        cursorY--;
        Platform.runLater(() -> { window.repaint(); });
    }

    private void moveCursorLeft() {
        if (cursorX == 0) {
            if (cursorY > 2) {
                window.turnOffBlink(cursorX, cursorY);
                cursorY--;
                cursorX = window.getGridWidth() - 1;
                Platform.runLater(() -> { window.repaint(); });
            }
        } else {
            window.turnOffBlink(cursorX, cursorY);
            cursorX--;
            Platform.runLater(() -> { window.repaint(); });
        }
    }

    private void moveCursorRight() {
        if (cursorX == window.getGridWidth() - 1) {
            if (cursorY < window.getGridHeight() - 1) {
                window.turnOffBlink(cursorX, cursorY);
                cursorY++;
                cursorX = 0;
                Platform.runLater(() -> { window.repaint(); });
            }
        } else {
            window.turnOffBlink(cursorX, cursorY);
            cursorX++;
            Platform.runLater(() -> { window.repaint(); });
        }
    }

    private void moveCursorDown() {
        if (cursorY == window.getGridHeight() - 1) {
            return;
        }

        window.turnOffBlink(cursorX, cursorY);
        cursorY++;
        Platform.runLater(() -> { window.repaint(); });
    }

    private final class CursorBlinkThread extends Thread {

        private static final long CURSOR_BLINK_SLEEP = 600L;

        private volatile boolean doRun = true;

        @Override
        public void run() {
            while (doRun) {
                try {
                    for (int i = 0; i < 10; i++) {
                        Thread.sleep(CURSOR_BLINK_SLEEP / 10);

                        if (!doRun) {
                            return;
                        }
                    }
                } catch (InterruptedException ex) {

                }

                window.toggleBlinkCursor(cursorX, cursorY);

                Platform.runLater(() -> { window.repaint(); });
            }
        }

        void requestExit() {
            doRun = false;
        }
    }

    private final class HelloWorldThread extends Thread {
        private final char[] textChars = new char[HELLO_WORLD_STRING.length()];
        private volatile boolean doRun = true;

        void requestExit() {
            doRun = false;
        }

        @Override
        public void run() {
            int xOffset = 
                    (window.getGridWidth() - HELLO_WORLD_STRING.length()) / 2;

            List<Character> characterList = 
                    new ArrayList<>(HELLO_WORLD_STRING.length());

            for (char ch : HELLO_WORLD_STRING.toCharArray()) {
                characterList.add(ch);
            }

            while (doRun) {
                try {
                    for (int i = 0; i < 10; i++) {
                        Thread.sleep(SLEEP_MILLISECONDS / 10);

                        if (!doRun) {
                            return;
                        }
                    }
                } catch (InterruptedException ex) {
                    return;
                }

                String text = toString(characterList, textChars);
                window.printString(xOffset, 0, text);
                Character ch = characterList.remove(0);
                characterList.add(ch);

                Platform.runLater(() -> { window.repaint(); });
            }
        }

        private static String toString(List<Character> charList,
                                       char[] chars) {
            for (int i = 0; i < chars.length; i++) {
                chars[i] = charList.get(i);
            }

            return new String(chars);
        }
    }

    private final class TextEditorKeyboardListener 
            implements TextUIWindowKeyboardListener {

        @Override
        public void onKeyTyped(KeyEvent event) {
            window.turnOffBlink(cursorX, cursorY);
            window.setChar(cursorX, cursorY, event.getCharacter().charAt(0));
            moveCursorRight();

            Platform.runLater(() -> { window.repaint(); });
            event.consume();
        }

        @Override
        public void onKeyPressed(KeyEvent event) {
            switch (event.getCode()) {
                case UP:
                    moveCursorUp();
                    break;

                case LEFT:
                    moveCursorLeft();
                    break;

                case RIGHT:
                    moveCursorRight();
                    break;

                case DOWN:
                    moveCursorDown();
                    break;
            }

            event.consume();
        }
    }

    private final class TextEditorMouseListener 
            implements TextUIWindowMouseListener {

        public void onMouseClick(MouseEvent event, int charX, int charY) {
            window.turnOffBlink(cursorX, cursorY);
            cursorX = charX;
            cursorY = charY;
        }
    }
}

Critique request

Can you spot any graphics/listener artifacts? Also, in the app class, on line 130, I have hardcoded the height for the HBox of the color pickers; would like to hear a word or two how to make this in a non-hard coded fashion.

(Some) Running instructions

I use JavaFX SDK 18.0.1 and the VM options:

--module-path "C:\Software\javafx-sdk-18.0.1\lib" --add-modules javafx.base,javafx.controls,javafx.fxml,javafx.graphics --add-exports javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED

(Change C:\Software\javafx-sdk-18.0.1\lib to point to your JavaFX SDK lib folder.) Also, I run everything from NetBeans 14. If you want to make a runnable .jar-file, you will need to configure the project pom.xml.