Spinogl
Spinogl

Reputation: 15

JavaFX 17 -> Custom TextArea/TextField Right Click Menu

I would like to ask a small question. Indeed, I want to customize the menu that appears when we make a right click in a textarea or a textfield. My goal would be to keep the basic menu (copy, paste, cut...) by adding the buttons I want.

I found this post that explains how to do it: JavaFX Append to right click menu for TextField

import com.sun.javafx.scene.control.skin.TextFieldSkin;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.Stage;

public class GiveMeContext extends Application {
    @Override
    public void start(final Stage stage) throws Exception {
        TextField textField = new TextField();
        TextFieldSkin customContextSkin = new TextFieldSkin(textField) {
            @Override
            public void populateContextMenu(ContextMenu contextMenu) {
                super.populateContextMenu(contextMenu);
                contextMenu.getItems().add(0, new SeparatorMenuItem());
                contextMenu.getItems().add(0, new MenuItem("Register"));
            }
        };
        textField.setSkin(customContextSkin);

        stage.setScene(new Scene(textField));
        stage.show();
    }
    public static void main(String[] args) throws Exception {
        launch(args);
    }
}

After trying, it works perfectly well for java 8, but as they were talking about it at the time, after java 9, it doesn't work anymore.

I tried to replace the problematic method (populateContextMenu) but unfortunately I couldn't find any way.

I would be very thankful if someone shows me how to do it using java 9+

Upvotes: 0

Views: 942

Answers (3)

Dave The Dane
Dave The Dane

Reputation: 869

As @user5182503 (Pavel K. ?) observed, in JavaFX 9+, access to the package containing the required Property Bundle is disallowed.

However, there is a new URL Scheme jrt: to read Content from the Runtime.

Here is an answer using that new functionality.

It was written and tested under Windows 11 Pro with the Zulu JDK FX 17 runtime from Azul Systems Inc. and is based on the answer submitted by @Silvio Barbieri.

Hope you like it:

package com.stackoverflow.q71053358;

import static javafx.scene.control.ScrollPane.ScrollBarPolicy.AS_NEEDED;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.StringJoiner;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.control.ChoiceDialog;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Control;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TextArea;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;

/**
 * Example for
 * <a href="https://stackoverflow.com/questions/71053358/">Stackoverflow Question 71053358</a>
 * <br><br>
 * Tested with Zulu JavaFX JDK 17.
 * <br><br>
 * Demonstrates use of the <code>jrt:</code> URL Scheme to access
 * Properties in Packages that in recent JDK's are not accessible.
 */
public class EmulateDefaultContextMenu extends Application {

    private static final class JrtURL {

        private static final String JAVA_RUNTIME_SCHEME = "jrt:";

        private        final URL    url;

        public JrtURL(final String module, final String package_, final String member) throws MalformedURLException {
            this.url = new URL(new StringJoiner("/")
                    .add(JAVA_RUNTIME_SCHEME)
                    .add(module)
                    .add(package_)
                    .add(member)
                    .toString());
        }

        public InputStream openStream() throws IOException {
            return this.url.openStream();
        }
    }

    private static final class Key {

        public final String key;

        public Key(final String... keyParts) {
            this.key = Stream.of(keyParts).collect(Collectors.joining());
        }
        public String lookupString(final ResourceBundle bundle) {
            return bundle.getString(this.key);
        }
    }

    public  static enum Ability {
        ENABLED,
        DISABLED;

        public boolean isEnabled()  {return this == ENABLED;}
        public boolean isDisabled() {return this == DISABLED;}
    }

    private static enum LogSeverity {
        ERROR,  // <- High Severity
        WARN,
        INFO,
        DEBUG,
        TRACE;  // <- Low Severity
    }

    private static final String   TEXT_AREA_MODULE     = "javafx.controls";
    private static final String   TEXT_AREA_PKG        = "com/sun/javafx/scene/control/skin/resources";
    private static final String   TEXT_AREA_PROPS      = "controls.properties";
    private static final String   TEXT_AREA_PROPS_DE   = "controls_de.properties";

    private static final String   TEXT_AREA_MENU       = "TextInputControl.menu.";

    private static final Key      TEXT_AREA_UNDO       = new Key(TEXT_AREA_MENU, "Undo");
    private static final Key      TEXT_AREA_REDO       = new Key(TEXT_AREA_MENU, "Redo");
    private static final Key      TEXT_AREA_CUT        = new Key(TEXT_AREA_MENU, "Cut");
    private static final Key      TEXT_AREA_COPY       = new Key(TEXT_AREA_MENU, "Copy");
    private static final Key      TEXT_AREA_PASTE      = new Key(TEXT_AREA_MENU, "Paste");
    private static final Key      TEXT_AREA_DELETE     = new Key(TEXT_AREA_MENU, "DeleteSelection");
    private static final Key      TEXT_AREA_SELECT_ALL = new Key(TEXT_AREA_MENU, "SelectAll");

    private        final TextArea logTextArea          = new TextArea();

    @Override
    public void start(final Stage primaryStage) throws Exception {
        /*
         * Set up Logging ScrollPane...
         */
        final var logScrollPane = new ScrollPane(logTextArea);

        logTextArea.setStyle   ("-fx-font-family: 'monospaced'");
        logTextArea.setEditable(false); // Side-effect.: CTRL-A, CTRL-C & CTRL-X are ignored

        logTextArea.addEventFilter(KeyEvent.KEY_PRESSED, e -> {

            if (e.isShortcutDown()) { // (CTRL on Win, META on Mac)

                if (e.getCode() == KeyCode.Y     // Suppress CTRL-Y
                ||  e.getCode() == KeyCode.Z) {  // Suppress CTRL-Z
                    e.consume();
                }
            }
        });

        logScrollPane.setHbarPolicy (AS_NEEDED);
        logScrollPane.setVbarPolicy (AS_NEEDED);
        logScrollPane.setFitToHeight(true);
        logScrollPane.setFitToWidth (true);

        /*
         * Generate the Context Menu...
         */
        try {
            final var jrtURL        = new JrtURL(TEXT_AREA_MODULE, TEXT_AREA_PKG, TEXT_AREA_PROPS);
            final var jrtURL_de     = new JrtURL(TEXT_AREA_MODULE, TEXT_AREA_PKG, TEXT_AREA_PROPS_DE);

            final var nullBundle    = getNullBundle();                          // Failing-all-else.: use Key as Title
            final var bundle_en     = getPropertyBundle(jrtURL,    nullBundle); // Fallback to English Titles
            final var bundle        = getPropertyBundle(jrtURL_de, bundle_en);  // German Titles, if available

            final var contextMenu   = newContextMenu(logTextArea);
            /*
             * For completeness, the following Items are ALL those that would be generated for a fully-enabled TextArea.
             * As our TextArea is not editable and CTRL-Y & CTRL-Z are ignored, some are superfluous.
             * The superfluous are assigned to a null Context Menu (i.e. none) & will therefore not appear.
             * Nevertheless, the Listeners for the full functionality are included.
             */
            final var itemUndo      = addMenuItem (null,        bundle, TEXT_AREA_UNDO,       Ability.DISABLED, e -> logTextArea.undo());
            final var itemRedo      = addMenuItem (null,        bundle, TEXT_AREA_REDO,       Ability.DISABLED, e -> logTextArea.redo());
            final var itemCut       = addMenuItem (null,        bundle, TEXT_AREA_CUT,        Ability.DISABLED, e -> logTextArea.cut());
            final var itemCopy      = addMenuItem (contextMenu, bundle, TEXT_AREA_COPY,       Ability.DISABLED, e -> logTextArea.copy());
            ;                         addMenuItem (null,        bundle, TEXT_AREA_PASTE,      Ability.ENABLED,  e -> logTextArea.paste());
            final var itemDelete    = addMenuItem (null,        bundle, TEXT_AREA_DELETE,     Ability.DISABLED, e -> deleteSelectedText());
            ;                         addSeparator(null);
            final var itemSelectAll = addMenuItem (contextMenu, bundle, TEXT_AREA_SELECT_ALL, Ability.DISABLED, e -> logTextArea.selectAll());
            ;                         addSeparator(contextMenu);
            ;                         addSeparator(contextMenu);
            ;                         addMenuItem (contextMenu,         "Change Log Level",   Ability.ENABLED,  e -> changeLogThreshold());

            logTextArea.undoableProperty() .addListener((obs, oldValue, newValue) -> itemUndo.setDisable(!newValue));
            logTextArea.redoableProperty() .addListener((obs, oldValue, newValue) -> itemRedo.setDisable(!newValue));
            logTextArea.selectionProperty().addListener((obs, oldValue, newValue) -> {
                itemCut      .setDisable(newValue.getLength() == 0);
                itemCopy     .setDisable(newValue.getLength() == 0);
                itemDelete   .setDisable(newValue.getLength() == 0);
                itemSelectAll.setDisable(newValue.getLength() == newValue.getEnd());
            });
        } catch (final IOException e) {
            e.printStackTrace();
        }

        /*
         * Set the Scene...
         */
        primaryStage.setTitle("Question 71053358");
        primaryStage.setScene(new Scene(logScrollPane, 480, 320));
        primaryStage.show();

        /*
         * Generate some Content every now-and-again...
         */
        final Runnable runnable  = () -> {
            Platform.runLater(() -> logTextArea.appendText(ZonedDateTime.now().toString() + '\n'));
        };
        Executors.newScheduledThreadPool(1).scheduleAtFixedRate(runnable, 2, 9, TimeUnit.SECONDS);
    }

    private static final PropertyResourceBundle getPropertyBundle(final JrtURL jrtURL, final ResourceBundle parentBundle) throws IOException {

        try (final var inputStream = jrtURL.openStream())
        {
            return new PropertyResourceBundle(inputStream) {
                {
                    this.setParent(parentBundle /* (may be null) */);
                }
            };
        }
    }

    private static final ResourceBundle getNullBundle() {
        return new       ResourceBundle() {
            @Override
            protected Object handleGetObject(final String key) {
                return key;
            }
            @Override
            public Enumeration<String> getKeys() {
                return Collections.emptyEnumeration();
            }
        };
    }

    private static ContextMenu newContextMenu(final Control control) {

        final ContextMenu      contextMenu = new ContextMenu();

        control.setContextMenu(contextMenu);

        return                 contextMenu;
    }

    private static MenuItem addMenuItem(final ContextMenu parent, final ResourceBundle  bundle,  final Key titleKey, final Ability ability, final EventHandler<ActionEvent> handler) {
        return              addMenuItem(                  parent, titleKey.lookupString(bundle),                                   ability,                                 handler);
    }

    private static MenuItem addMenuItem(final ContextMenu parent,                                final String title, final Ability ability, final EventHandler<ActionEvent> handler) {

        final var                 child = new MenuItem(title);
        ;                         child.setDisable (ability.isDisabled());
        ;                         child.setOnAction(handler);

        if (parent != null) {
            parent.getItems().add(child);
        }

        return                    child;
    }

    private static SeparatorMenuItem addSeparator(final ContextMenu parent) {

        final var                 child = new SeparatorMenuItem();

        if (parent != null) {
            parent.getItems().add(child);
        }

        return                    child;
    }

    private void deleteSelectedText() {

        final var range = logTextArea.getSelection();

        if (range.getLength() == 0) {
            return;
        }
        final var                 text    = logTextArea.getText();
        final var                 newText = text.substring(0, range.getStart()) + text.substring(range.getEnd());

        logTextArea.setText      (newText);
        logTextArea.positionCaret(range.getStart());
    }

    private void changeLogThreshold() {

        final var header  =
                """
                Only messages with a Severity
                greater than or equal to the Threshold
                will be logged.
                """;

        final var choices = Arrays.asList(LogSeverity.values());

        final var chooser = new ChoiceDialog<LogSeverity>(LogSeverity.INFO, choices);
        ;         chooser.setTitle      ("Log Level");
        ;         chooser.setContentText("Threshold.:");
        ;         chooser.setHeaderText (header);

        ;         chooser.showAndWait().ifPresent(choice -> logTextArea.appendText("-> " + choice + '\n'));
    }

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

Upvotes: 0

Silvio Barbieri
Silvio Barbieri

Reputation: 105

After long hours of programming I found a way to "extend" the default context menu of a TextInputControl. I have to rebuild it from scratch, but it's not so complex as it may seem.

My code:

import java.util.Collection;
import java.util.ResourceBundle;
import java.util.function.Consumer;

import org.apache.commons.lang3.StringUtils;

import javafx.scene.control.ContextMenu;
import javafx.scene.control.IndexRange;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TextInputControl;

public interface JFXTextUtils {

    static void initializeContextMenu(TextInputControl textField) {
        final MenuItem undoMI = new ContextMenuItem("Undo", textField, TextInputControl::undo);
        final MenuItem redoMI = new ContextMenuItem("Redo", textField, TextInputControl::redo);
        final MenuItem cutMI = new ContextMenuItem("Cut", textField, TextInputControl::cut);
        final MenuItem copyMI = new ContextMenuItem("Copy", textField, TextInputControl::copy);
        final MenuItem pasteMI = new ContextMenuItem("Paste", textField, TextInputControl::paste);
        final MenuItem selectAllMI = new ContextMenuItem("SelectAll", textField, TextInputControl::selectAll);
        final MenuItem deleteMI = new ContextMenuItem("DeleteSelection", textField, JFXTextUtils::deleteSelectedText);

        textField.undoableProperty().addListener((obs, oldValue, newValue) -> undoMI.setDisable(!newValue));
        textField.redoableProperty().addListener((obs, oldValue, newValue) -> redoMI.setDisable(!newValue));
        textField.selectionProperty().addListener((obs, oldValue, newValue) -> {
            cutMI.setDisable(newValue.getLength() == 0);
            copyMI.setDisable(newValue.getLength() == 0);
            deleteMI.setDisable(newValue.getLength() == 0);
            selectAllMI.setDisable(newValue.getLength() == newValue.getEnd());
        });

        undoMI.setDisable(true);
        redoMI.setDisable(true);
        cutMI.setDisable(true);
        copyMI.setDisable(true);
        deleteMI.setDisable(true);
        selectAllMI.setDisable(true);

        textField.setContextMenu(ContextMenu(undoMI, redoMI, cutMI, copyMI, pasteMI, deleteMI, new SeparatorMenuItem(), selectAllMI,
                new SeparatorMenuItem()));
    }

    static void deleteSelectedText(TextInputControl t) {
        IndexRange range = t.getSelection();
        if (range.getLength() == 0) {
            return;
        }
        String text = t.getText();
        String newText = text.substring(0, range.getStart()) + text.substring(range.getEnd());
        t.setText(newText);
        t.positionCaret(range.getStart());
    }

    class ContextMenuItem extends MenuItem {
        ContextMenuItem(final String action, TextInputControl textField, Consumer<TextInputControl> function) {
            super(ResourceBundle.getBundle("com/sun/javafx/scene/control/skin/resources/controls")
                    .getString("TextInputControl.menu." + action));
            setOnAction(e -> function.accept(textField));
        }
    }

}

This code recreate exactly the default context menu and is ready to accept more MenuItem after the last MenuSeparator.

Upvotes: 1

user5182503
user5182503

Reputation:

Your code won't work in JavaFX 9+ because of modularization. For details read this. The only thing you can do is to use context menu and fill it with your own values. A full example to do it in JavaFX 17 is below.

Step 1. Create new project.

Pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.mycompany</groupId>
    <artifactId>mavenproject1</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.9.0</version>
            </plugin>
        </plugins>
    </build>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>
    
    <dependencies>
           <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-controls</artifactId>
                <version>17.0.2-ea+2</version>
                <scope>compile</scope>
            </dependency>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-base</artifactId>
                <version>17.0.2-ea+2</version>
                <scope>compile</scope>
            </dependency>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-fxml</artifactId>
                <version>17.0.2-ea+2</version>
                <scope>compile</scope>
            </dependency>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-graphics</artifactId>
                <version>17.0.2-ea+2</version>
                <scope>compile</scope>
            </dependency>
    </dependencies>
</project>

module-info:

module Mavenproject1 {
    requires javafx.controls;
    requires javafx.base;
    requires javafx.fxml;
    requires javafx.graphics;
    opens com.mycompany;
}

Main class:

package com.mycompany;


import javafx.scene.control.skin.TextFieldSkin;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TextField;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.Stage;

public class NewMain2 extends Application {
    
    @Override
    public void start(final Stage stage) throws Exception {
        TextField textField = new TextField();
        
        ContextMenu contextMenu = new ContextMenu();
        MenuItem menuItem1 = new MenuItem("Choice 1");
        MenuItem menuItem2 = new MenuItem("Choice 2");
        MenuItem menuItem3 = new MenuItem("Choice 3");
        contextMenu.getItems().addAll(menuItem1, menuItem2, menuItem3);
        
        textField.setContextMenu(contextMenu);

        stage.setScene(new Scene(textField));
        stage.show();
    }
    public static void main(String[] args) throws Exception {
        launch(args);
    }
}

Step 2. Build you project.

Step 3. Download JavaFX SDK from here.

Step 4 Run you project this way

 java --module-path ./mavenproject1-1.0-SNAPSHOT.jar:/opt/javafx-sdk-17.0.2/lib --add-modules=javafx.controls,javafx.fxml -m Mavenproject1/com.mycompany.NewMain2

Upvotes: 1

Related Questions