JavaFX TextArea – получение границ курсора

Я пытаюсь показать всплывающее окно под курсором текстовой области. Это означает, что мне нужны координаты X и Y.

В настоящее время я пытаюсь добиться этого следующим образом:

        final var textFieldBounds = input.getBoundsInParent();
        final var bounds = ((TextAreaSkin) input.getSkin()).getCaretBounds()
        completionList.setTranslateX(textFieldBounds.getMinX() + bounds.getMinX());
        completionList.setTranslateY(textFieldBounds.getMinY() + bounds.getMaxY());

Хотя в целом это работает хорошо, функция getCaretBounds всегда отстает от одного события каретки:

Я уже пробовал использовать Platform.runLater, но это не решит проблему.

Я реагирую на изменение в слушателе caretPosition. Есть ли лучший момент/лучший способ добиться этого?

Я также пробовал использовать значение caretPosition для ручного получения границ символов через скин, но это тоже не решает проблему.

Упрощенная версия кода:

package link.biosmarcel.baka.view;

import javafx.application.Application;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ListView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextInputControl;
import javafx.scene.control.skin.TextAreaSkin;
import javafx.scene.layout.Background;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;

public class MRE extends Application {
    public static void main(String[] args) {
        Application.launch(MRE.class, args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        primaryStage.setTitle("AutocompleteField Demo");

        final var autocompleteField = new MREAutocompleteTextArea((string2) -> {
            List<String> available = new ArrayList<>();
            if ("reference".startsWith(string2)) {
                available.add("reference");
            }
            if ("name".startsWith(string2)) {
                available.add("name");
            }
            if ("booking_date".startsWith(string2)) {
                available.add("booking_date");
            }
            if ("effective_date".startsWith(string2)) {
                available.add("effective_date");
            }
            return available;
        });
        final var scene = new Scene(new VBox(autocompleteField.getNode()), 400, 300);
        primaryStage.setScene(scene);

        primaryStage.show();
    }

    private static class MREAutocompleteTextArea {

        private final Pane pane;
        public final TextInputControl input;
        protected final ListView<String> completionList;

        public MREAutocompleteTextArea(Function<String, List<String>> autocompleteGenerator) {
            pane = new Pane();
            input = createInput();
            completionList = new ListView<>();

            // This is what is the Z-Index on the web. It allows us to render our popup above everything else.
            pane.getChildren().add(input);
            pane.getChildren().add(completionList);
            pane.maxHeightProperty().bind(input.heightProperty());
            pane.maxWidthProperty().bind(input.widthProperty());

            completionList.setFixedCellSize(35.0);
            completionList.setMaxHeight(8 * completionList.getFixedCellSize());
            completionList.setBackground(Background.fill(Color.WHITE));
            completionList.setFocusTraversable(false);
            completionList.setVisible(false);

            input.focusedProperty().addListener((_, _, _) -> {
                if (!canShowPopup()) {
                    hidePopup();
                    return;
                }

                updatePopupItems(autocompleteGenerator.apply(input.getText().substring(0, input.getCaretPosition())));
                refreshPopup();
            });

            input.caretPositionProperty().addListener((_, _, newValue) -> {
                if (!canShowPopup()) {
                    return;
                }

                System.out.println(newValue.intValue());
                updatePopupItems(autocompleteGenerator.apply(input.getText().substring(0, newValue.intValue())));
                refreshPopup();
            });
        }

        private boolean canShowPopup() {
            return input.isFocused() && !input.isDisabled();
        }

        private void complete() {
            final String selectedItem = completionList.getSelectionModel().getSelectedItem();
            // selection is always nullable
            //noinspection ConstantValue
            if (selectedItem != null) {
                final var textBeforeCaret = input.getText().substring(0, input.getCaretPosition());
                final var lastSpace = textBeforeCaret.lastIndexOf(' ');
                final var preCompletionText = textBeforeCaret.substring(0, lastSpace + 1);

                // FIXME Use insert?
                input.setText(preCompletionText + selectedItem + " " + input.getText().substring(input.getCaretPosition()));
                // We add a space at the end, so we can start writing / completing the next token type right away.
                input.positionCaret(preCompletionText.length() + selectedItem.length() + 1);
            }
        }

        private void updatePopupItems(final Collection<String> newItems) {
            completionList.getItems().removeIf(item -> !newItems.contains(item));
            for (final String newItem : newItems) {
                if (!completionList.getItems().contains(newItem)) {
                    completionList.getItems().add(newItem);
                }
            }
            completionList.getItems().sort(String::compareTo);
        }

        public void hidePopup() {
            completionList.setVisible(false);
        }

        public void refreshPopup() {
            if (completionList.getItems().isEmpty()) {
                hidePopup();
                return;
            }

            // +2 to prevent an unnecessary scrollbar
            if (completionList.getSelectionModel().getSelectedIndex() == -1) {
                completionList.getSelectionModel().select(0);
            }

            completionList.setPrefHeight(completionList.getItems().size() * completionList.getFixedCellSize() + 2);
            positionPopup();
            toFront(completionList);

            completionList.setVisible(true);
        }

        private void toFront(Node node) {
            node.setViewOrder(-1);
            if (node.getParent() != null) {
                toFront(node.getParent());
            }
        }

        public Node getNode() {
            return pane;
        }

        void positionPopup() {
            final var textFieldBounds = input.getBoundsInParent();
            final var bounds = ((TextAreaSkin) input.getSkin()).getCaretBounds();
            completionList.setTranslateX(textFieldBounds.getMinX() + bounds.getMinX());
            completionList.setTranslateY(textFieldBounds.getMinY() + bounds.getMaxY());
        }

        TextInputControl createInput() {
            return new TextArea();
        }
    }
}

Чтобы воспроизвести, введите boo, нажмите Enter и нажмите Backspace.

ОБНОВЛЯТЬ

Теперь я нашел обходной путь. Пока звонил reuqestLayout, не получилось, звонок layout дал желаемый эффект. Я не уверен, могут ли это иметь какие-либо недостатки, поскольку я предполагаю, что это означает, что я потенциально пересылаю в каком-то грязном состоянии?

Можете ли вы создать и опубликовать минимально воспроизводимый пример ?

James_D 19.07.2024 18:27

добавил пример @James_D

Marcel 19.07.2024 18:35

Ваш звонок на layout(), вероятно, будет самым простым решением. Я подозреваю, что снижение производительности не будет заметно для пользователя, но его всегда можно измерить. Другой вариант — получить ссылку на Path, представляющую курсор, а затем напрямую прослушать его свойство boundsInLocal (при необходимости переводя границы в другое координатное пространство; см. различные вспомогательные методы xxxToYYY для Node).

Slaw 20.07.2024 02:05

Да, я тоже рассматривал возможность доступа к пути через отражение, но я хочу, чтобы отражение было низким, так как в какой-то момент я планирую использовать graalvm. Или есть другой способ получить его? Насколько я вижу, это внутри contentView, который находится внутри ScrollPane. Хотя существует множество способов, каждый из них требует размышлений?

Marcel 20.07.2024 14:23
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
4
58
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Если layout() вам подходит, то это, вероятно, самое простое решение. В любом случае это, пожалуй, лучшее решение, поскольку оно, похоже, не зависит от каких-либо деталей реализации. Я также подозреваю, что любое снижение производительности будет минимальным, хотя и заметным для конечного пользователя (особенно если вы создаете собственный образ с помощью GraalVM).

Альтернативный подход

Тем не менее, другой подход — получить ссылку на Path, представляющий каретку. Таким образом, вы можете напрямую наблюдать за границами курсора и реагировать соответствующим образом. К сожалению, узел каретки не имеет идентификатора CSS или класса стиля. По крайней мере, не в JavaFX 22. Это означает, что использование Node::lookup невозможно. Но есть как минимум два других подхода, которые вы можете использовать, хотя оба они основаны на деталях реализации (и, следовательно, являются хрупкими решениями).

Прогулка по графу сцены

Вы можете изучить, как TextAreaSkin создает граф сцены для TextArea. Затем вы можете получить доступ к соответствующим спискам дочерних элементов и тому подобному, выполняя кастинг по ходу дела. Один из способов сделать это достаточно надежно — инкапсулировать это в свой собственный скин.

package com.example;

import javafx.scene.Parent;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextArea;
import javafx.scene.control.skin.TextAreaSkin;
import javafx.scene.shape.Path;

public class CustomTextAreaSkin extends TextAreaSkin {

  private final Path caretPath;

  public CustomTextAreaSkin(TextArea control) {
    super(control);
    var scroll = (ScrollPane) getChildren().getFirst();
    var content = (Parent) scroll.getContent();
    caretPath = (Path) content.getChildrenUnmodifiable().getLast();
  }

  public Path getCaretPath() {
    return caretPath;
  }
}

Note: The above was tested with Java 22.0.2 and JavaFX 22.0.2. Modifications may be necessary if using a different version of Java and/or JavaFX.

Тогда вы можете использовать его так:

var area = new TextArea();
var skin = new CustomTextAreaSkin(area);
area.setSkin(skin);

var caret = skin.getCaretPath();
// use 'caret'...

Если хотите, вы можете установить скин через CSS.

/* use different selector(s) as desired */
.text-area {
    -fx-skin: "com.example.CustomTextAreaSkin";
}

Но вам придется подождать, пока CSS не будет применен к элементу управления, прежде чем вы сможете получить скин. Обычно это означает ожидание, пока элемент управления не отобразится в отображаемом окне хотя бы один раз.

Отражение

Другой вариант — использовать отражение для получения каретки.

package com.example;

import javafx.scene.control.TextInputControl;
import javafx.scene.control.skin.TextInputControlSkin;
import javafx.scene.shape.Path;

public class CaretHelper {

  public static Path getCaretPath(TextInputControl control) {
    var skin = control.getSkin();
    if (skin == null)
      throw new IllegalStateException("control has no skin");
    if (!(skin instanceof TextInputControlSkin<?>))
      throw new IllegalStateException("control's skin wrong type");
    
    try {
      var field = TextInputControlSkin.class.getDeclaredField("caretPath");
      field.setAccessible(true);
      return (Path) field.get(skin);
    } catch (ReflectiveOperationException ex) {
      throw new RuntimeException(ex);
    }
  }
}

Note: The above was tested with Java 22.0.2 and JavaFX 22.0.2. Modifications may be necessary if using a different version of Java and/or JavaFX.

Если JavaFX разрешен как именованные модули, вам необходимо передать:

--add-opens javafx.controls/javafx.scene.control.skin=<target>

При запуске приложения или создании собственного образа GraalVM. Хотя, к счастью, поскольку приведенный выше код использует литерал класса, а "caretPath" является константой времени компиляции, отражение должно «просто работать» в собственном образе без какой-либо дополнительной настройки.

Кроме того, перед вызовом getCaretPath убедитесь, что оформление текстовой области установлено. При желании вы можете установить его вручную. В противном случае вам обычно придется ждать, пока текстовая область окажется в окне, пока она была показана хотя бы один раз. Точнее, скин по умолчанию не будет установлен до тех пор, пока CSS не будет применен к элементу управления.


Пример

Вот пример использования модифицированной версии кода из раздела «Обход по графику сцены». Он был протестирован с использованием Java 22.0.2 и JavaFX 22.0.2 в Windows 10.

Main.java

package com.example;

import javafx.application.Application;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.layout.StackPane;
import javafx.stage.Popup;
import javafx.stage.Stage;

public class Main extends Application {

  @Override
  public void start(Stage primaryStage) {
    var area = new TextArea();
    var skin = new CustomTextAreaSkin(area);
    area.setSkin(skin);
    installCoordinatePopup(skin.getCaretPath(), skin.caretScreenBounds());

    var root = new StackPane(area);
    root.setPadding(new Insets(5));

    primaryStage.setScene(new Scene(root, 600, 400));
    primaryStage.show();
  }

  private void installCoordinatePopup(Node caret, ObservableValue<Bounds> caretScreenBounds) {
    var label = new Label();
    label.setStyle(
        """
        -fx-background-color: black, white;
        -fx-background-insets: 0, 1;
        -fx-background-radius: 3, 2;
        -fx-padding: 3;
        """);

    var popup = new Popup();
    popup.getContent().add(label);

    caretScreenBounds.subscribe(bounds -> {
      if (bounds == null) {
        popup.hide();
        return;
      }

      double minX = bounds.getMinX();
      double minY = bounds.getMinY();
      label.setText("X: %.2f, Y: %.2f".formatted(minX, minY));

      double anchorX = minX;
      double anchorY = bounds.getMaxY() + 3;
      if (popup.isShowing()) {
        popup.setAnchorX(anchorX);
        popup.setAnchorY(anchorY);
      } else {
        popup.show(caret, anchorX, anchorY);
      }
    });
  }
}

CustomTextAreaSkin.java

import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.ObjectExpression;
import javafx.geometry.Bounds;
import javafx.scene.Parent;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextArea;
import javafx.scene.control.skin.TextAreaSkin;
import javafx.scene.shape.Path;

public class CustomTextAreaSkin extends TextAreaSkin {

  private final Path caretPath;

  private ObjectBinding<Bounds> caretScreenBounds;
  private boolean invalidate = true;

  public CustomTextAreaSkin(TextArea control) {
    super(control);
    var scroll = (ScrollPane) getChildren().getFirst();
    var content = (Parent) scroll.getContent();
    caretPath = (Path) content.getChildrenUnmodifiable().getLast();
  }

  public final Path getCaretPath() {
    return caretPath;
  }

  public final ObjectExpression<Bounds> caretScreenBounds() {
    if (caretScreenBounds == null) {
      caretScreenBounds =
          Bindings.createObjectBinding(() -> caretPath.localToScreen(caretPath.getBoundsInLocal()));
      // this listener will be automatically disposed when appropriate
      registerInvalidationListener(caretPath.boundsInLocalProperty(), _ -> {
        // try to minimize calls to runLater as much as possible
        if (caretScreenBounds.isValid() && invalidate) {
          invalidate = false;
          Platform.runLater(() -> {
            invalidate = true;
            caretScreenBounds.invalidate();
          });
        }
      });
    }
    return caretScreenBounds;
  }
}

Эта версия кода добавляет метод caretScreenBounds() в класс скина и использует Platform::runLater. Почему? Потому что по какой-то причине прокрутка нарушает локальные границы вычисления границ экрана. Звонок runLater — важная часть. Без него вы столкнетесь со следующими проблемами:

  • Когда ввод отдельных символов приводит к прокрутке текстовой области, границы не совсем правильные. Это приводит к тому, что всплывающее окно «покачивается» при вводе текста.

  • При большой прокрутке, например при переходе к следующей строке после ввода очень длинной строки, границы значительно смещаются. Это часто приводит к тому, что всплывающее окно уходит куда-то за пределы окна, обычно в том направлении, в котором произошла прокрутка. Всплывающее окно будет оставаться в неправильном месте до тех пор, пока границы курсора не обновятся (например, при вводе чего-либо).

Возможно, есть решение лучше, чем runLater, но я не смог его найти. Я даже пытался создать привязку, зависящую от макета, локальных и родительских границ каждого узла, от курсора до самой текстовой области включительно, но это все равно не решило проблему.

Отличный ответ! Если мне удастся исправить проблемы с прокруткой, я отредактирую это. Но сейчас это очень низкий приоритет.

Marcel 21.07.2024 11:36

@Marcel Я нашел решение проблемы с прокруткой. Обновил пример. Интересно, поможет ли это решение заставить ваш исходный код работать с runLater?

Slaw 22.07.2024 05:01

Ох, сумасшедший. Я поиграюсь с этим в следующие дни. Спасибо за информацию.

Marcel 22.07.2024 15:25

Другие вопросы по теме