modules/controls/src/main/java/javafx/scene/control/skin/TextAreaSkin.java

Print this page
rev 9240 : 8076423: JEP 253: Prepare JavaFX UI Controls & CSS APIs for Modularization

@@ -21,13 +21,15 @@
  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  * or visit www.oracle.com if you need additional information or have any
  * questions.
  */
 
-package com.sun.javafx.scene.control.skin;
+package javafx.scene.control.skin;
 
+import com.sun.javafx.scene.control.behavior.BehaviorBase;
 import com.sun.javafx.scene.control.behavior.TextAreaBehavior;
+import com.sun.javafx.scene.control.skin.Utils;
 import com.sun.javafx.scene.text.HitInfo;
 import javafx.animation.KeyFrame;
 import javafx.animation.Timeline;
 import javafx.application.Platform;
 import javafx.beans.binding.BooleanBinding;

@@ -46,10 +48,13 @@
 import javafx.geometry.VPos;
 import javafx.geometry.VerticalDirection;
 import javafx.scene.AccessibleAttribute;
 import javafx.scene.Group;
 import javafx.scene.Node;
+import javafx.scene.control.Accordion;
+import javafx.scene.control.Button;
+import javafx.scene.control.Control;
 import javafx.scene.control.IndexRange;
 import javafx.scene.control.ScrollPane;
 import javafx.scene.control.TextArea;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.input.ScrollEvent;

@@ -60,466 +65,223 @@
 import javafx.scene.text.Text;
 import javafx.util.Duration;
 
 import java.util.List;
 
+import static com.sun.javafx.PlatformUtil.isMac;
+import static com.sun.javafx.PlatformUtil.isWindows;
+
 /**
- * Text area skin.
+ * Default skin implementation for the {@link TextArea} control.
+ *
+ * @see TextArea
+ * @since 9
  */
-public class TextAreaSkin extends TextInputControlSkin<TextArea, TextAreaBehavior> {
+public class TextAreaSkin extends TextInputControlSkin<TextArea> {
+
+    /**************************************************************************
+     *
+     * Static fields
+     *
+     **************************************************************************/
+
+    /** A shared helper object, used only by downLines(). */
+    private static final Path tmpCaretPath = new Path();
+
+
+
+    /**************************************************************************
+     *
+     * Private fields
+     *
+     **************************************************************************/
 
     final private TextArea textArea;
 
     // *** NOTE: Multiple node mode is not yet fully implemented *** //
     private final boolean USE_MULTIPLE_NODES = false;
 
+    private final TextAreaBehavior behavior;
+
     private double computedMinWidth = Double.NEGATIVE_INFINITY;
     private double computedMinHeight = Double.NEGATIVE_INFINITY;
     private double computedPrefWidth = Double.NEGATIVE_INFINITY;
     private double computedPrefHeight = Double.NEGATIVE_INFINITY;
     private double widthForComputedPrefHeight = Double.NEGATIVE_INFINITY;
     private double characterWidth;
     private double lineHeight;
 
-    @Override protected void invalidateMetrics() {
-        computedMinWidth = Double.NEGATIVE_INFINITY;
-        computedMinHeight = Double.NEGATIVE_INFINITY;
-        computedPrefWidth = Double.NEGATIVE_INFINITY;
-        computedPrefHeight = Double.NEGATIVE_INFINITY;
-    }
+    private ContentView contentView = new ContentView();
+    private Group paragraphNodes = new Group();
 
-    private class ContentView extends Region {
-        {
-            getStyleClass().add("content");
+    private Text promptNode;
+    private ObservableBooleanValue usePromptText;
 
-            addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
-                getBehavior().mousePressed(event);
-                event.consume();
-            });
+    private ObservableIntegerValue caretPosition;
+    private Group selectionHighlightGroup = new Group();
 
-            addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
-                getBehavior().mouseReleased(event);
-                event.consume();
-            });
+    private ScrollPane scrollPane;
+    private Bounds oldViewportBounds;
 
-            addEventHandler(MouseEvent.MOUSE_DRAGGED, event -> {
-                getBehavior().mouseDragged(event);
-                event.consume();
-            });
-        }
+    private VerticalDirection scrollDirection = null;
 
-        @Override protected ObservableList<Node> getChildren() {
-            return super.getChildren();
-        }
+    private Path characterBoundingPath = new Path();
 
-        @Override public Orientation getContentBias() {
-            return Orientation.HORIZONTAL;
+    private Timeline scrollSelectionTimeline = new Timeline();
+    private EventHandler<ActionEvent> scrollSelectionHandler = event -> {
+        switch (scrollDirection) {
+            case UP: {
+                // TODO Get previous offset
+                break;
         }
 
-        @Override protected double computePrefWidth(double height) {
-            if (computedPrefWidth < 0) {
-                double prefWidth = 0;
-
-                for (Node node : paragraphNodes.getChildren()) {
-                    Text paragraphNode = (Text)node;
-                    prefWidth = Math.max(prefWidth,
-                            Utils.computeTextWidth(paragraphNode.getFont(),
-                                    paragraphNode.getText(), 0));
+            case DOWN: {
+                // TODO Get next offset
+                break;
+            }
                 }
+    };
 
-                prefWidth += snappedLeftInset() + snappedRightInset();
+    private double pressX, pressY; // For dragging handles on embedded
+    private boolean handlePressed;
 
-                Bounds viewPortBounds = scrollPane.getViewportBounds();
-                computedPrefWidth = Math.max(prefWidth, (viewPortBounds != null) ? viewPortBounds.getWidth() : 0);
-            }
-            return computedPrefWidth;
-        }
+    /**
+     * Remembers horizontal position when traversing up / down.
+     */
+    double targetCaretX = -1;
 
-        @Override
-        protected double computePrefHeight(double width) {
-            if (width != widthForComputedPrefHeight) {
-                invalidateMetrics();
-                widthForComputedPrefHeight = width;
-            }
 
-            if (computedPrefHeight < 0) {
-                double wrappingWidth;
-                if (width == -1) {
-                    wrappingWidth = 0;
-                } else {
-                    wrappingWidth = Math.max(width - (snappedLeftInset() + snappedRightInset()), 0);
-                }
 
-                double prefHeight = 0;
+    /**************************************************************************
+     *
+     * Constructors
+     *
+     **************************************************************************/
 
-                for (Node node : paragraphNodes.getChildren()) {
-                    Text paragraphNode = (Text)node;
-                    prefHeight += Utils.computeTextHeight(
-                            paragraphNode.getFont(),
-                            paragraphNode.getText(),
-                            wrappingWidth, 
-                            paragraphNode.getBoundsType());
-                }
+    /**
+     * Creates a new TextAreaSkin instance, installing the necessary child
+     * nodes into the Control {@link Control#getChildren() children} list, as
+     * well as the necessary input mappings for handling key, mouse, etc events.
+     *
+     * @param control The control that this skin should be installed onto.
+     */
+    public TextAreaSkin(final TextArea control) {
+        super(control);
 
-                prefHeight += snappedTopInset() + snappedBottomInset();
+        // install default input map for the text area control
+        this.behavior = new TextAreaBehavior(control);
+        this.behavior.setTextAreaSkin(this);
+//        control.setInputMap(behavior.getInputMap());
 
-                Bounds viewPortBounds = scrollPane.getViewportBounds();
-                computedPrefHeight = Math.max(prefHeight, (viewPortBounds != null) ? viewPortBounds.getHeight() : 0);
-            }
-            return computedPrefHeight;
-        }
+        this.textArea = control;
 
-        @Override protected double computeMinWidth(double height) {
-            if (computedMinWidth < 0) {
-                double hInsets = snappedLeftInset() + snappedRightInset();
-                computedMinWidth = Math.min(characterWidth + hInsets, computePrefWidth(height));
+        caretPosition = new IntegerBinding() {
+            { bind(control.caretPositionProperty()); }
+            @Override protected int computeValue() {
+                return control.getCaretPosition();
             }
-            return computedMinWidth;
+        };
+        caretPosition.addListener((observable, oldValue, newValue) -> {
+            targetCaretX = -1;
+            if (newValue.intValue() > oldValue.intValue()) {
+                setForwardBias(true);
         }
+        });
 
-        @Override protected double computeMinHeight(double width) {
-            if (computedMinHeight < 0) {
-                double vInsets = snappedTopInset() + snappedBottomInset();
-                computedMinHeight = Math.min(lineHeight + vInsets, computePrefHeight(width));
-            }
-            return computedMinHeight;
+        forwardBiasProperty().addListener(observable -> {
+            if (control.getWidth() > 0) {
+                updateTextNodeCaretPos(control.getCaretPosition());
         }
+        });
 
-        @Override
-        public void layoutChildren() {
-            TextArea textArea = getSkinnable();
-            double width = getWidth();
-
-            // Lay out paragraphs
-            final double topPadding = snappedTopInset();
-            final double leftPadding = snappedLeftInset();
-
-            double wrappingWidth = Math.max(width - (leftPadding + snappedRightInset()), 0);
+//        setManaged(false);
 
-            double y = topPadding;
+        // Initialize content
+        scrollPane = new ScrollPane();
+        scrollPane.setFitToWidth(control.isWrapText());
+        scrollPane.setContent(contentView);
+        getChildren().add(scrollPane);
             
-            final List<Node> paragraphNodesChildren = paragraphNodes.getChildren();
+        getSkinnable().addEventFilter(ScrollEvent.ANY, event -> {
+            if (event.isDirect() && handlePressed) {
+                event.consume();
+            }
+        });
 
-            for (int i = 0; i < paragraphNodesChildren.size(); i++) {
-                Node node = paragraphNodesChildren.get(i);
-                Text paragraphNode = (Text)node;
-                paragraphNode.setWrappingWidth(wrappingWidth);
+        // Add selection
+        selectionHighlightGroup.setManaged(false);
+        selectionHighlightGroup.setVisible(false);
+        contentView.getChildren().add(selectionHighlightGroup);
 
-                Bounds bounds = paragraphNode.getBoundsInLocal();
-                paragraphNode.setLayoutX(leftPadding);
-                paragraphNode.setLayoutY(y);
+        // Add content view
+        paragraphNodes.setManaged(false);
+        contentView.getChildren().add(paragraphNodes);
                 
-                y += bounds.getHeight();
+        // Add caret
+        caretPath.setManaged(false);
+        caretPath.setStrokeWidth(1);
+        caretPath.fillProperty().bind(textFillProperty());
+        caretPath.strokeProperty().bind(textFillProperty());
+        // modifying visibility of the caret forces a layout-pass (RT-32373), so
+        // instead we modify the opacity.
+        caretPath.opacityProperty().bind(new DoubleBinding() {
+            { bind(caretVisibleProperty()); }
+            @Override protected double computeValue() {
+                return caretVisibleProperty().get() ? 1.0 : 0.0;
             }
+        });
+        contentView.getChildren().add(caretPath);
 
-            if (promptNode != null) {
-                promptNode.setLayoutX(leftPadding);
-                promptNode.setLayoutY(topPadding + promptNode.getBaselineOffset());
-                promptNode.setWrappingWidth(wrappingWidth);
+        if (SHOW_HANDLES) {
+            contentView.getChildren().addAll(caretHandle, selectionHandle1, selectionHandle2);
             }
 
-            // Update the selection
-            IndexRange selection = textArea.getSelection();
-            Bounds oldCaretBounds = caretPath.getBoundsInParent();
+        scrollPane.hvalueProperty().addListener((observable, oldValue, newValue) -> {
+            getSkinnable().setScrollLeft(newValue.doubleValue() * getScrollLeftMax());
+        });
 
-            selectionHighlightGroup.getChildren().clear();
+        scrollPane.vvalueProperty().addListener((observable, oldValue, newValue) -> {
+            getSkinnable().setScrollTop(newValue.doubleValue() * getScrollTopMax());
+        });
 
-            int caretPos = textArea.getCaretPosition();
-            int anchorPos = textArea.getAnchor();
+        // Initialize the scroll selection timeline
+        scrollSelectionTimeline.setCycleCount(Timeline.INDEFINITE);
+        List<KeyFrame> scrollSelectionFrames = scrollSelectionTimeline.getKeyFrames();
+        scrollSelectionFrames.clear();
+        scrollSelectionFrames.add(new KeyFrame(Duration.millis(350), scrollSelectionHandler));
 
-            if (SHOW_HANDLES) {
-                // Install and resize the handles for caret and anchor.
-                if (selection.getLength() > 0) {
-                    selectionHandle1.resize(selectionHandle1.prefWidth(-1),
-                                            selectionHandle1.prefHeight(-1));
-                    selectionHandle2.resize(selectionHandle2.prefWidth(-1),
-                                            selectionHandle2.prefHeight(-1));
-                } else {
-                    caretHandle.resize(caretHandle.prefWidth(-1),
-                                       caretHandle.prefHeight(-1));
+        // Add initial text content
+        for (int i = 0, n = USE_MULTIPLE_NODES ? control.getParagraphs().size() : 1; i < n; i++) {
+            CharSequence paragraph = (n == 1) ? control.textProperty().getValueSafe() : control.getParagraphs().get(i);
+            addParagraphNode(i, paragraph.toString());
                 }
 
-                // Position the handle for the anchor. This could be handle1 or handle2.
-                // Do this before positioning the actual caret.
-                if (selection.getLength() > 0) {
-                    int paragraphIndex = paragraphNodesChildren.size();
-                    int paragraphOffset = textArea.getLength() + 1;
-                    Text paragraphNode = null;
-                    do {
-                        paragraphNode = (Text)paragraphNodesChildren.get(--paragraphIndex);
-                        paragraphOffset -= paragraphNode.getText().length() + 1;
-                    } while (anchorPos < paragraphOffset);
-
-                    updateTextNodeCaretPos(anchorPos - paragraphOffset);
-                    caretPath.getElements().clear();
-                    caretPath.getElements().addAll(paragraphNode.getImpl_caretShape());
-                    caretPath.setLayoutX(paragraphNode.getLayoutX());
-                    caretPath.setLayoutY(paragraphNode.getLayoutY());
+        control.selectionProperty().addListener((observable, oldValue, newValue) -> {
+            // TODO Why do we need two calls here?
+            control.requestLayout();
+            contentView.requestLayout();
+        });
 
-                    Bounds b = caretPath.getBoundsInParent();
-                    if (caretPos < anchorPos) {
-                        selectionHandle2.setLayoutX(b.getMinX() - selectionHandle2.getWidth() / 2);
-                        selectionHandle2.setLayoutY(b.getMaxY() - 1);
-                    } else {
-                        selectionHandle1.setLayoutX(b.getMinX() - selectionHandle1.getWidth() / 2);
-                        selectionHandle1.setLayoutY(b.getMinY() - selectionHandle1.getHeight() + 1);
-                    }
-                }
-            }
+        control.wrapTextProperty().addListener((observable, oldValue, newValue) -> {
+            invalidateMetrics();
+            scrollPane.setFitToWidth(newValue);
+        });
 
-            {
-                // Position caret
-                int paragraphIndex = paragraphNodesChildren.size();
-                int paragraphOffset = textArea.getLength() + 1;
+        control.prefColumnCountProperty().addListener((observable, oldValue, newValue) -> {
+            invalidateMetrics();
+            updatePrefViewportWidth();
+        });
 
-                Text paragraphNode = null;
-                do {
-                    paragraphNode = (Text)paragraphNodesChildren.get(--paragraphIndex);
-                    paragraphOffset -= paragraphNode.getText().length() + 1;
-                } while (caretPos < paragraphOffset);
+        control.prefRowCountProperty().addListener((observable, oldValue, newValue) -> {
+            invalidateMetrics();
+            updatePrefViewportHeight();
+        });
 
-                updateTextNodeCaretPos(caretPos - paragraphOffset);
-
-                caretPath.getElements().clear();
-                caretPath.getElements().addAll(paragraphNode.getImpl_caretShape());
-
-                caretPath.setLayoutX(paragraphNode.getLayoutX());
-
-                // TODO: Remove this temporary workaround for RT-27533
-                paragraphNode.setLayoutX(2 * paragraphNode.getLayoutX() - paragraphNode.getBoundsInParent().getMinX());
-
-                caretPath.setLayoutY(paragraphNode.getLayoutY());
-                if (oldCaretBounds == null || !oldCaretBounds.equals(caretPath.getBoundsInParent())) {
-                    scrollCaretToVisible();
-                }
-            }
-
-            // Update selection fg and bg
-            int start = selection.getStart();
-            int end = selection.getEnd();
-            for (int i = 0, max = paragraphNodesChildren.size(); i < max; i++) {
-                Node paragraphNode = paragraphNodesChildren.get(i);
-                Text textNode = (Text)paragraphNode;
-                int paragraphLength = textNode.getText().length() + 1;
-                if (end > start && start < paragraphLength) {
-                    textNode.setImpl_selectionStart(start);
-                    textNode.setImpl_selectionEnd(Math.min(end, paragraphLength));
-
-                    Path selectionHighlightPath = new Path();
-                    selectionHighlightPath.setManaged(false);
-                    selectionHighlightPath.setStroke(null);
-                    PathElement[] selectionShape = textNode.getImpl_selectionShape();
-                    if (selectionShape != null) {
-                        selectionHighlightPath.getElements().addAll(selectionShape);
-                    }
-                    selectionHighlightGroup.getChildren().add(selectionHighlightPath);
-                    selectionHighlightGroup.setVisible(true);
-                    selectionHighlightPath.setLayoutX(textNode.getLayoutX());
-                    selectionHighlightPath.setLayoutY(textNode.getLayoutY());
-                    updateHighlightFill();
-                } else {
-                    textNode.setImpl_selectionStart(-1);
-                    textNode.setImpl_selectionEnd(-1);
-                    selectionHighlightGroup.setVisible(false);
-                }
-                start = Math.max(0, start - paragraphLength);
-                end   = Math.max(0, end   - paragraphLength);
-            }
-
-            if (SHOW_HANDLES) {
-                // Position handle for the caret. This could be handle1 or handle2 when
-                // a selection is active.
-                Bounds b = caretPath.getBoundsInParent();
-                if (selection.getLength() > 0) {
-                    if (caretPos < anchorPos) {
-                        selectionHandle1.setLayoutX(b.getMinX() - selectionHandle1.getWidth() / 2);
-                        selectionHandle1.setLayoutY(b.getMinY() - selectionHandle1.getHeight() + 1);
-                    } else {
-                        selectionHandle2.setLayoutX(b.getMinX() - selectionHandle2.getWidth() / 2);
-                        selectionHandle2.setLayoutY(b.getMaxY() - 1);
-                    }
-                } else {
-                    caretHandle.setLayoutX(b.getMinX() - caretHandle.getWidth() / 2 + 1);
-                    caretHandle.setLayoutY(b.getMaxY());
-                }
-            }
-
-            if (scrollPane.getPrefViewportWidth() == 0
-                || scrollPane.getPrefViewportHeight() == 0) {
-                updatePrefViewportWidth();
-                updatePrefViewportHeight();
-                if (getParent() != null && scrollPane.getPrefViewportWidth() > 0
-                                        || scrollPane.getPrefViewportHeight() > 0) {
-                    // Force layout of viewRect in ScrollPaneSkin
-                    getParent().requestLayout();
-                }
-            }
-
-            // RT-36454: Fit to width/height only if smaller than viewport.
-            // That is, grow to fit but don't shrink to fit.
-            Bounds viewportBounds = scrollPane.getViewportBounds();
-            boolean wasFitToWidth = scrollPane.isFitToWidth();
-            boolean wasFitToHeight = scrollPane.isFitToHeight();
-            boolean setFitToWidth = textArea.isWrapText() || computePrefWidth(-1) <= viewportBounds.getWidth();
-            boolean setFitToHeight = computePrefHeight(width) <= viewportBounds.getHeight();
-            if (wasFitToWidth != setFitToWidth || wasFitToHeight != setFitToHeight) {
-                Platform.runLater(() -> {
-                    scrollPane.setFitToWidth(setFitToWidth);
-                    scrollPane.setFitToHeight(setFitToHeight);
-                });
-                getParent().requestLayout();
-            }
-        }
-    }
-
-    private ContentView contentView = new ContentView();
-    private Group paragraphNodes = new Group();
-
-    private Text promptNode;
-    private ObservableBooleanValue usePromptText;
-
-    private ObservableIntegerValue caretPosition;
-    private Group selectionHighlightGroup = new Group();
-
-    private ScrollPane scrollPane;
-    private Bounds oldViewportBounds;
-
-    private VerticalDirection scrollDirection = null;
-
-    private Path characterBoundingPath = new Path();
-
-    private Timeline scrollSelectionTimeline = new Timeline();
-    private EventHandler<ActionEvent> scrollSelectionHandler = event -> {
-        switch (scrollDirection) {
-            case UP: {
-                // TODO Get previous offset
-                break;
-            }
-
-            case DOWN: {
-                // TODO Get next offset
-                break;
-            }
-        }
-    };
-
-    public static final int SCROLL_RATE = 30;
-
-    private double pressX, pressY; // For dragging handles on embedded
-    private boolean handlePressed;
-
-    public TextAreaSkin(final TextArea textArea) {
-        super(textArea, new TextAreaBehavior(textArea));
-        getBehavior().setTextAreaSkin(this);
-        this.textArea = textArea;
-
-        caretPosition = new IntegerBinding() {
-            { bind(textArea.caretPositionProperty()); }
-            @Override protected int computeValue() {
-                return textArea.getCaretPosition();
-            }
-        };
-        caretPosition.addListener((observable, oldValue, newValue) -> {
-            targetCaretX = -1;
-            if (newValue.intValue() > oldValue.intValue()) {
-                setForwardBias(true);
-            }
-        });
-
-        forwardBiasProperty().addListener(observable -> {
-            if (textArea.getWidth() > 0) {
-                updateTextNodeCaretPos(textArea.getCaretPosition());
-            }
-        });
-
-//        setManaged(false);
-
-        // Initialize content
-        scrollPane = new ScrollPane();
-        scrollPane.setFitToWidth(textArea.isWrapText());
-        scrollPane.setContent(contentView);
-        getChildren().add(scrollPane);
-
-        getSkinnable().addEventFilter(ScrollEvent.ANY, event -> {
-            if (event.isDirect() && handlePressed) {
-                event.consume();
-            }
-        });
-
-        // Add selection
-        selectionHighlightGroup.setManaged(false);
-        selectionHighlightGroup.setVisible(false);
-        contentView.getChildren().add(selectionHighlightGroup);
-
-        // Add content view
-        paragraphNodes.setManaged(false);
-        contentView.getChildren().add(paragraphNodes);
-
-        // Add caret
-        caretPath.setManaged(false);
-        caretPath.setStrokeWidth(1);
-        caretPath.fillProperty().bind(textFill);
-        caretPath.strokeProperty().bind(textFill);
-        // modifying visibility of the caret forces a layout-pass (RT-32373), so
-        // instead we modify the opacity.
-        caretPath.opacityProperty().bind(new DoubleBinding() {
-            { bind(caretVisible); }
-            @Override protected double computeValue() {
-                return caretVisible.get() ? 1.0 : 0.0;
-            }
-        });
-        contentView.getChildren().add(caretPath);
-
-        if (SHOW_HANDLES) {
-            contentView.getChildren().addAll(caretHandle, selectionHandle1, selectionHandle2);
-        }
-
-        scrollPane.hvalueProperty().addListener((observable, oldValue, newValue) -> {
-            getSkinnable().setScrollLeft(newValue.doubleValue() * getScrollLeftMax());
-        });
-
-        scrollPane.vvalueProperty().addListener((observable, oldValue, newValue) -> {
-            getSkinnable().setScrollTop(newValue.doubleValue() * getScrollTopMax());
-        });
-
-        // Initialize the scroll selection timeline
-        scrollSelectionTimeline.setCycleCount(Timeline.INDEFINITE);
-        List<KeyFrame> scrollSelectionFrames = scrollSelectionTimeline.getKeyFrames();
-        scrollSelectionFrames.clear();
-        scrollSelectionFrames.add(new KeyFrame(Duration.millis(350), scrollSelectionHandler));
-
-        // Add initial text content
-        for (int i = 0, n = USE_MULTIPLE_NODES ? textArea.getParagraphs().size() : 1; i < n; i++) {
-            CharSequence paragraph = (n == 1) ? textArea.textProperty().getValueSafe() : textArea.getParagraphs().get(i);
-            addParagraphNode(i, paragraph.toString());
-        }
-
-        textArea.selectionProperty().addListener((observable, oldValue, newValue) -> {
-            // TODO Why do we need two calls here?
-            textArea.requestLayout();
-            contentView.requestLayout();
-        });
-
-        textArea.wrapTextProperty().addListener((observable, oldValue, newValue) -> {
-            invalidateMetrics();
-            scrollPane.setFitToWidth(newValue);
-        });
-
-        textArea.prefColumnCountProperty().addListener((observable, oldValue, newValue) -> {
-            invalidateMetrics();
-            updatePrefViewportWidth();
-        });
-
-        textArea.prefRowCountProperty().addListener((observable, oldValue, newValue) -> {
-            invalidateMetrics();
-            updatePrefViewportHeight();
-        });
-
-        updateFontMetrics();
-        fontMetrics.addListener(valueModel -> {
-            updateFontMetrics();
-        });
+        updateFontMetrics();
+        fontMetrics.addListener(valueModel -> {
+            updateFontMetrics();
+        });
 
         contentView.paddingProperty().addListener(valueModel -> {
             updatePrefViewportWidth();
             updatePrefViewportHeight();
         });

@@ -539,24 +301,24 @@
                     contentView.requestLayout();
                 }
             }
         });
 
-        textArea.scrollTopProperty().addListener((observable, oldValue, newValue) -> {
+        control.scrollTopProperty().addListener((observable, oldValue, newValue) -> {
             double vValue = (newValue.doubleValue() < getScrollTopMax())
                                ? (newValue.doubleValue() / getScrollTopMax()) : 1.0;
             scrollPane.setVvalue(vValue);
         });
 
-        textArea.scrollLeftProperty().addListener((observable, oldValue, newValue) -> {
+        control.scrollLeftProperty().addListener((observable, oldValue, newValue) -> {
             double hValue = (newValue.doubleValue() < getScrollLeftMax())
                                ? (newValue.doubleValue() / getScrollLeftMax()) : 1.0;
             scrollPane.setHvalue(hValue);
         });
 
         if (USE_MULTIPLE_NODES) {
-            textArea.getParagraphs().addListener((ListChangeListener.Change<? extends CharSequence> change) -> {
+            control.getParagraphs().addListener((ListChangeListener.Change<? extends CharSequence> change) -> {
                 while (change.next()) {
                     int from = change.getFrom();
                     int to = change.getTo();
                     List<? extends CharSequence> removed = change.getRemoved();
                     if (from < to) {

@@ -579,22 +341,22 @@
                         paragraphNodes.getChildren().subList(from, from + removed.size()).clear();
                     }
                 }
             });
         } else {
-            textArea.textProperty().addListener(observable -> {
+            control.textProperty().addListener(observable -> {
                 invalidateMetrics();
-                ((Text)paragraphNodes.getChildren().get(0)).setText(textArea.textProperty().getValueSafe());
+                ((Text)paragraphNodes.getChildren().get(0)).setText(control.textProperty().getValueSafe());
                 contentView.requestLayout();
             });
         }
 
         usePromptText = new BooleanBinding() {
-            { bind(textArea.textProperty(), textArea.promptTextProperty()); }
+            { bind(control.textProperty(), control.promptTextProperty()); }
             @Override protected boolean computeValue() {
-                String txt = textArea.getText();
-                String promptTxt = textArea.getPromptText();
+                String txt = control.getText();
+                String promptTxt = control.getPromptText();
                 return ((txt == null || txt.isEmpty()) &&
                         promptTxt != null && !promptTxt.isEmpty());
             }
         };
 

@@ -602,17 +364,17 @@
             createPromptNode();
         }
 
         usePromptText.addListener(observable -> {
             createPromptNode();
-            textArea.requestLayout();
+            control.requestLayout();
         });
 
         updateHighlightFill();
         updatePrefViewportWidth();
         updatePrefViewportHeight();
-        if (textArea.isFocused()) setCaretAnimating(true);
+        if (control.isFocused()) setCaretAnimating(true);
 
         if (SHOW_HANDLES) {
             selectionHandle1.setRotate(180);
 
             EventHandler<MouseEvent> handlePressHandler = e -> {

@@ -648,585 +410,329 @@
                     if (element instanceof MoveTo && ((MoveTo)element).getY() > e.getY() - getTextTranslateY()) {
                         hit.setCharIndex(pos - 1);
                     }
                     textNode.setImpl_caretPosition(oldPos);
                 }
-                positionCaret(hit, false, false);
+                positionCaret(hit, false);
                 e.consume();
             });
 
-            selectionHandle1.setOnMouseDragged(new EventHandler<MouseEvent>() {
-                @Override public void handle(MouseEvent e) {
-                    TextArea textArea = getSkinnable();
+            selectionHandle1.setOnMouseDragged(e -> {
+                TextArea control1 = getSkinnable();
                     Text textNode = getTextNode();
                     Point2D tp = textNode.localToScene(0, 0);
                     Point2D p = new Point2D(e.getSceneX() - tp.getX() + 10/*??*/ - pressX + selectionHandle1.getWidth() / 2,
                                             e.getSceneY() - tp.getY() - pressY + selectionHandle1.getHeight() + 5);
                     HitInfo hit = textNode.impl_hitTestChar(translateCaretPosition(p));
                     int pos = hit.getCharIndex();
-                    if (textArea.getAnchor() < textArea.getCaretPosition()) {
+                if (control1.getAnchor() < control1.getCaretPosition()) {
                         // Swap caret and anchor
-                        textArea.selectRange(textArea.getCaretPosition(), textArea.getAnchor());
+                    control1.selectRange(control1.getCaretPosition(), control1.getAnchor());
                     }
                     if (pos > 0) {
-                        if (pos >= textArea.getAnchor()) {
-                            pos = textArea.getAnchor();
+                    if (pos >= control1.getAnchor()) {
+                        pos = control1.getAnchor();
                         }
                         int oldPos = textNode.getImpl_caretPosition();
                         textNode.setImpl_caretPosition(pos);
                         PathElement element = textNode.getImpl_caretShape()[0];
                         if (element instanceof MoveTo && ((MoveTo)element).getY() > e.getY() - getTextTranslateY()) {
                             hit.setCharIndex(pos - 1);
                         }
                         textNode.setImpl_caretPosition(oldPos);
                     }
-                    positionCaret(hit, true, false);
+                positionCaret(hit, true);
                     e.consume();
-                }
             });
 
-            selectionHandle2.setOnMouseDragged(new EventHandler<MouseEvent>() {
-                @Override public void handle(MouseEvent e) {
-                    TextArea textArea = getSkinnable();
+            selectionHandle2.setOnMouseDragged(e -> {
+                TextArea control1 = getSkinnable();
                     Text textNode = getTextNode();
                     Point2D tp = textNode.localToScene(0, 0);
                     Point2D p = new Point2D(e.getSceneX() - tp.getX() + 10/*??*/ - pressX + selectionHandle2.getWidth() / 2,
                                             e.getSceneY() - tp.getY() - pressY - 6);
                     HitInfo hit = textNode.impl_hitTestChar(translateCaretPosition(p));
                     int pos = hit.getCharIndex();
-                    if (textArea.getAnchor() > textArea.getCaretPosition()) {
+                if (control1.getAnchor() > control1.getCaretPosition()) {
                         // Swap caret and anchor
-                        textArea.selectRange(textArea.getCaretPosition(), textArea.getAnchor());
+                    control1.selectRange(control1.getCaretPosition(), control1.getAnchor());
                     }
                     if (pos > 0) {
-                        if (pos <= textArea.getAnchor() + 1) {
-                            pos = Math.min(textArea.getAnchor() + 2, textArea.getLength());
+                    if (pos <= control1.getAnchor() + 1) {
+                        pos = Math.min(control1.getAnchor() + 2, control1.getLength());
                         }
                         int oldPos = textNode.getImpl_caretPosition();
                         textNode.setImpl_caretPosition(pos);
                         PathElement element = textNode.getImpl_caretShape()[0];
                         if (element instanceof MoveTo && ((MoveTo)element).getY() > e.getY() - getTextTranslateY()) {
                             hit.setCharIndex(pos - 1);
                         }
                         textNode.setImpl_caretPosition(oldPos);
-                        positionCaret(hit, true, false);
+                    positionCaret(hit, true);
                     }
                     e.consume();
-                }
             });
         }
     }
 
-    @Override
-    protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
-        scrollPane.resizeRelocate(contentX, contentY, contentWidth, contentHeight);
-    }
 
-    private void createPromptNode() {
-        if (promptNode == null && usePromptText.get()) {
-            promptNode = new Text();
-            contentView.getChildren().add(0, promptNode);
-            promptNode.setManaged(false);
-            promptNode.getStyleClass().add("text");
-            promptNode.visibleProperty().bind(usePromptText);
-            promptNode.fontProperty().bind(getSkinnable().fontProperty());
-            promptNode.textProperty().bind(getSkinnable().promptTextProperty());
-            promptNode.fillProperty().bind(promptTextFill);
-        }
-    }
 
-    private void addParagraphNode(int i, String string) {
-        final TextArea textArea = getSkinnable();
-        Text paragraphNode = new Text(string);
-        paragraphNode.setTextOrigin(VPos.TOP);
-        paragraphNode.setManaged(false);
-        paragraphNode.getStyleClass().add("text");
-        paragraphNode.boundsTypeProperty().addListener((observable, oldValue, newValue) -> {
-            invalidateMetrics();
-            updateFontMetrics();
-        });
-        paragraphNodes.getChildren().add(i, paragraphNode);
+    /***************************************************************************
+     *                                                                         *
+     * Public API                                                              *
+     *                                                                         *
+     **************************************************************************/
 
-        paragraphNode.fontProperty().bind(textArea.fontProperty());
-        paragraphNode.fillProperty().bind(textFill);
-        paragraphNode.impl_selectionFillProperty().bind(highlightTextFill);
+    /** {@inheritDoc} */
+    @Override protected void invalidateMetrics() {
+        computedMinWidth = Double.NEGATIVE_INFINITY;
+        computedMinHeight = Double.NEGATIVE_INFINITY;
+        computedPrefWidth = Double.NEGATIVE_INFINITY;
+        computedPrefHeight = Double.NEGATIVE_INFINITY;
     }
 
-    @Override
-    public void dispose() {
-        // TODO Unregister listeners on text editor, paragraph list
-        throw new UnsupportedOperationException();
+    /** {@inheritDoc} */
+    @Override protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
+        scrollPane.resizeRelocate(contentX, contentY, contentWidth, contentHeight);
     }
 
-    @Override
-    public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) {
-        Text firstParagraph = (Text) paragraphNodes.getChildren().get(0);
-        return Utils.getAscent(getSkinnable().getFont(),firstParagraph.getBoundsType())
-                + contentView.snappedTopInset() + textArea.snappedTopInset();
+    /** {@inheritDoc} */
+    @Override protected void updateHighlightFill() {
+        for (Node node : selectionHighlightGroup.getChildren()) {
+            Path selectionHighlightPath = (Path)node;
+            selectionHighlightPath.setFill(highlightFillProperty().get());
+        }
     }
 
-    @Override
-    public char getCharacter(int index) {
-        int n = paragraphNodes.getChildren().size();
-
-        int paragraphIndex = 0;
-        int offset = index;
-
-        String paragraph = null;
-        while (paragraphIndex < n) {
-            Text paragraphNode = (Text)paragraphNodes.getChildren().get(paragraphIndex);
-            paragraph = paragraphNode.getText();
-            int count = paragraph.length() + 1;
+    // Public for behavior
+    /**
+     * Performs a hit test, mapping point to index in the content.
+     *
+     * @param x the x coordinate of the point.
+     * @param y the y coordinate of the point.
+     * @return a {@code TextPosInfo} object describing the index and forward bias.
+     */
+    public TextPosInfo getIndex(double x, double y) {
+        // adjust the event to be in the same coordinate space as the
+        // text content of the textInputControl
+        Text textNode = getTextNode();
+        Point2D p = new Point2D(x - textNode.getLayoutX(), y - getTextTranslateY());
+        HitInfo hit = textNode.impl_hitTestChar(translateCaretPosition(p));
+        int pos = hit.getCharIndex();
+        if (pos > 0) {
+            int oldPos = textNode.getImpl_caretPosition();
+            textNode.setImpl_caretPosition(pos);
+            PathElement element = textNode.getImpl_caretShape()[0];
+            if (element instanceof MoveTo && ((MoveTo)element).getY() > y - getTextTranslateY()) {
+                hit.setCharIndex(pos - 1);
+            }
+            textNode.setImpl_caretPosition(oldPos);
+        }
+        return new TextPosInfo(hit);
+    };
 
-            if (offset < count) {
+    /** {@inheritDoc} */
+    @Override public void moveCaret(TextUnit unit, Direction dir, boolean select) {
+        switch (unit) {
+            case CHARACTER:
+                switch (dir) {
+                    case LEFT:
+                    case RIGHT:
+                        nextCharacterVisually(dir == Direction.RIGHT);
                 break;
+                    default:
+                        throw new IllegalArgumentException(""+dir);
             }
+                break;
 
-            offset -= count;
-            paragraphIndex++;
+            case LINE:
+                switch (dir) {
+                    case UP:
+                        previousLine(select);
+                        break;
+                    case DOWN:
+                        nextLine(select);
+                        break;
+                    case BEGINNING:
+                        lineStart(select, select && isMac());
+                        break;
+                    case END:
+                        lineEnd(select, select && isMac());
+                        break;
+                    default:
+                        throw new IllegalArgumentException(""+dir);
         }
+                break;
 
-        return offset == paragraph.length() ? '\n' : paragraph.charAt(offset);
+            case PAGE:
+                switch (dir) {
+                    case UP:
+                        previousPage(select);
+                        break;
+                    case DOWN:
+                        nextPage(select);
+                        break;
+                    default:
+                        throw new IllegalArgumentException(""+dir);
     }
+                break;
 
-    @Override
-    public int getInsertionPoint(double x, double y) {
-        TextArea textArea = getSkinnable();
-
-        int n = paragraphNodes.getChildren().size();
-        int index = -1;
-
-        if (n > 0) {
-            if (y < contentView.snappedTopInset()) {
-                // Select the character at x in the first row
-                Text paragraphNode = (Text)paragraphNodes.getChildren().get(0);
-                index = getNextInsertionPoint(paragraphNode, x, -1, VerticalDirection.DOWN);
-            } else if (y > contentView.snappedTopInset() + contentView.getHeight()) {
-                // Select the character at x in the last row
-                int lastParagraphIndex = n - 1;
-                Text lastParagraphView = (Text)paragraphNodes.getChildren().get(lastParagraphIndex);
-
-                index = getNextInsertionPoint(lastParagraphView, x, -1, VerticalDirection.UP)
-                    + (textArea.getLength() - lastParagraphView.getText().length());
-            } else {
-                // Select the character at x in the row at y
-                int paragraphOffset = 0;
-                for (int i = 0; i < n; i++) {
-                    Text paragraphNode = (Text)paragraphNodes.getChildren().get(i);
-
-                    Bounds bounds = paragraphNode.getBoundsInLocal();
-                    double paragraphViewY = paragraphNode.getLayoutY() + bounds.getMinY();
-                    if (y >= paragraphViewY
-                        && y < paragraphViewY + paragraphNode.getBoundsInLocal().getHeight()) {
-                        index = getInsertionPoint(paragraphNode,
-                            x - paragraphNode.getLayoutX(),
-                            y - paragraphNode.getLayoutY()) + paragraphOffset;
+            case PARAGRAPH:
+                switch (dir) {
+                    case UP:
+                        paragraphStart(true, select);
+                        break;
+                    case DOWN:
+                        paragraphEnd(true, select);
                         break;
+                    case BEGINNING:
+                        paragraphStart(false, select);
+                        break;
+                    case END:
+                        paragraphEnd(false, select);
+                        break;
+                    default:
+                        throw new IllegalArgumentException(""+dir);
                     }
+                break;
 
-                    paragraphOffset += paragraphNode.getText().length() + 1;
-                }
+            default:
+                throw new IllegalArgumentException(""+unit);
             }
         }
 
-        return index;
+    private void nextCharacterVisually(boolean moveRight) {
+        if (isRTL()) {
+            // Text node is mirrored.
+            moveRight = !moveRight;
     }
 
-    public void positionCaret(HitInfo hit, boolean select, boolean extendSelection) {
-        int pos = Utils.getHitInsertionIndex(hit, getSkinnable().getText());
-        boolean isNewLine =
-               (pos > 0 &&
-                pos <= getSkinnable().getLength() &&
-                getSkinnable().getText().codePointAt(pos-1) == 0x0a);
-
-        // special handling for a new line
-        if (!hit.isLeading() && isNewLine) {
-            hit.setLeading(true);
-            pos -= 1;
+        Text textNode = getTextNode();
+        Bounds caretBounds = caretPath.getLayoutBounds();
+        if (caretPath.getElements().size() == 4) {
+            // The caret is split
+            // TODO: Find a better way to get the primary caret position
+            // instead of depending on the internal implementation.
+            // See RT-25465.
+            caretBounds = new Path(caretPath.getElements().get(0), caretPath.getElements().get(1)).getLayoutBounds();
         }
-
-        if (select) {
-            if (extendSelection) {
-                getSkinnable().extendSelection(pos);
+        double hitX = moveRight ? caretBounds.getMaxX() : caretBounds.getMinX();
+        double hitY = (caretBounds.getMinY() + caretBounds.getMaxY()) / 2;
+        HitInfo hit = textNode.impl_hitTestChar(new Point2D(hitX, hitY));
+        Path charShape = new Path(textNode.impl_getRangeShape(hit.getCharIndex(), hit.getCharIndex() + 1));
+        if ((moveRight && charShape.getLayoutBounds().getMaxX() > caretBounds.getMaxX()) ||
+                (!moveRight && charShape.getLayoutBounds().getMinX() < caretBounds.getMinX())) {
+            hit.setLeading(!hit.isLeading());
+            positionCaret(hit, false);
             } else {
-                getSkinnable().selectPositionCaret(pos);
-            }
+            // We're at beginning or end of line. Try moving up / down.
+            int dot = textArea.getCaretPosition();
+            targetCaretX = moveRight ? 0 : Double.MAX_VALUE;
+            // TODO: Use Bidi sniffing instead of assuming right means forward here?
+            downLines(moveRight ? 1 : -1, false, false);
+            targetCaretX = -1;
+            if (dot == textArea.getCaretPosition()) {
+                if (moveRight) {
+                    textArea.forward();
         } else {
-            getSkinnable().positionCaret(pos);
-        }
-
-        setForwardBias(hit.isLeading());
+                    textArea.backward();
     }
-
-    private double getScrollTopMax() {
-        return Math.max(0, contentView.getHeight() - scrollPane.getViewportBounds().getHeight());
     }
-
-    private double getScrollLeftMax() {
-        return Math.max(0, contentView.getWidth() - scrollPane.getViewportBounds().getWidth());
     }
-
-    private int getInsertionPoint(Text paragraphNode, double x, double y) {
-        HitInfo hitInfo = paragraphNode.impl_hitTestChar(new Point2D(x, y));
-        return Utils.getHitInsertionIndex(hitInfo, paragraphNode.getText());
     }
 
-    public int getNextInsertionPoint(double x, int from, VerticalDirection scrollDirection) {
-        // TODO
-        return 0;
-    }
+    private void downLines(int nLines, boolean select, boolean extendSelection) {
+        Text textNode = getTextNode();
+        Bounds caretBounds = caretPath.getLayoutBounds();
 
-    private int getNextInsertionPoint(Text paragraphNode, double x, int from,
-        VerticalDirection scrollDirection) {
-        // TODO
-        return 0;
+        // The middle y coordinate of the the line we want to go to.
+        double targetLineMidY = (caretBounds.getMinY() + caretBounds.getMaxY()) / 2 + nLines * lineHeight;
+        if (targetLineMidY < 0) {
+            targetLineMidY = 0;
     }
 
-    @Override
-    public Rectangle2D getCharacterBounds(int index) {
-        TextArea textArea = getSkinnable();
-
-        int paragraphIndex = paragraphNodes.getChildren().size();
-        int paragraphOffset = textArea.getLength() + 1;
+        // The target x for the caret. This may have been set during a
+        // previous call.
+        double x = (targetCaretX >= 0) ? targetCaretX : (caretBounds.getMaxX());
 
-        Text paragraphNode = null;
-        do {
-            paragraphNode = (Text)paragraphNodes.getChildren().get(--paragraphIndex);
-            paragraphOffset -= paragraphNode.getText().length() + 1;
-        } while (index < paragraphOffset);
+        // Find a text position for the target x,y.
+        HitInfo hit = textNode.impl_hitTestChar(translateCaretPosition(new Point2D(x, targetLineMidY)));
+        int pos = hit.getCharIndex();
 
-        int characterIndex = index - paragraphOffset;
-        boolean terminator = false;
+        // Save the old pos temporarily while testing the new one.
+        int oldPos = textNode.getImpl_caretPosition();
+        boolean oldBias = textNode.isImpl_caretBias();
+        textNode.setImpl_caretBias(hit.isLeading());
+        textNode.setImpl_caretPosition(pos);
+        tmpCaretPath.getElements().clear();
+        tmpCaretPath.getElements().addAll(textNode.getImpl_caretShape());
+        tmpCaretPath.setLayoutX(textNode.getLayoutX());
+        tmpCaretPath.setLayoutY(textNode.getLayoutY());
+        Bounds tmpCaretBounds = tmpCaretPath.getLayoutBounds();
+        // The y for the middle of the row we found.
+        double foundLineMidY = (tmpCaretBounds.getMinY() + tmpCaretBounds.getMaxY()) / 2;
+        textNode.setImpl_caretBias(oldBias);
+        textNode.setImpl_caretPosition(oldPos);
 
-        if (characterIndex == paragraphNode.getText().length()) {
-            characterIndex--;
-            terminator = true;
+        if (pos > 0) {
+            if (nLines > 0 && foundLineMidY > targetLineMidY) {
+                // We went too far and ended up after a newline.
+                hit.setCharIndex(pos - 1);
         }
 
-        characterBoundingPath.getElements().clear();
-        characterBoundingPath.getElements().addAll(paragraphNode.impl_getRangeShape(characterIndex, characterIndex + 1));
-        characterBoundingPath.setLayoutX(paragraphNode.getLayoutX());
-        characterBoundingPath.setLayoutY(paragraphNode.getLayoutY());
+            if (pos >= textArea.getLength() && getCharacter(pos - 1) == '\n') {
+                // Special case for newline at end of text.
+                hit.setLeading(true);
+            }
+        }
 
-        Bounds bounds = characterBoundingPath.getBoundsInLocal();
+        // Test if the found line is in the correct direction and move
+        // the caret.
+        if (nLines == 0 ||
+                (nLines > 0 && foundLineMidY > caretBounds.getMaxY()) ||
+                (nLines < 0 && foundLineMidY < caretBounds.getMinY())) {
 
-        double x = bounds.getMinX() + paragraphNode.getLayoutX() - textArea.getScrollLeft();
-        double y = bounds.getMinY() + paragraphNode.getLayoutY() - textArea.getScrollTop();
+            positionCaret(hit, select, extendSelection);
+            targetCaretX = x;
+        }
+    }
 
-        // Sometimes the bounds is empty, in which case we must ignore the width/height
-        double width = bounds.isEmpty() ? 0 : bounds.getWidth();
-        double height = bounds.isEmpty() ? 0 : bounds.getHeight();
+    private void previousLine(boolean select) {
+        downLines(-1, select, false);
+    }
 
-        if (terminator) {
-            x += width;
-            width = 0;
+    private void nextLine(boolean select) {
+        downLines(1, select, false);
         }
 
-        return new Rectangle2D(x, y, width, height);
+    private void previousPage(boolean select) {
+        downLines(-(int)(scrollPane.getViewportBounds().getHeight() / lineHeight),
+                select, false);
     }
 
-    @Override public void scrollCharacterToVisible(final int index) {
-        // TODO We queue a callback because when characters are added or
-        // removed the bounds are not immediately updated; is this really
-        // necessary?
+    private void nextPage(boolean select) {
+        downLines((int)(scrollPane.getViewportBounds().getHeight() / lineHeight),
+                select, false);
+    }
 
-        Platform.runLater(() -> {
-            if (getSkinnable().getLength() == 0) {
-                return;
+    private void lineStart(boolean select, boolean extendSelection) {
+        targetCaretX = 0;
+        downLines(0, select, extendSelection);
+        targetCaretX = -1;
             }
-            Rectangle2D characterBounds = getCharacterBounds(index);
-            scrollBoundsToVisible(characterBounds);
-        });
+
+    private void lineEnd(boolean select, boolean extendSelection) {
+        targetCaretX = Double.MAX_VALUE;
+        downLines(0, select, extendSelection);
+        targetCaretX = -1;
     }
 
-    private void scrollCaretToVisible() {
+
+    private void paragraphStart(boolean previousIfAtStart, boolean select) {
         TextArea textArea = getSkinnable();
-        Bounds bounds = caretPath.getLayoutBounds();
-        double x = bounds.getMinX() - textArea.getScrollLeft();
-        double y = bounds.getMinY() - textArea.getScrollTop();
-        double w = bounds.getWidth();
-        double h = bounds.getHeight();
-
-        if (SHOW_HANDLES) {
-            if (caretHandle.isVisible()) {
-                h += caretHandle.getHeight();
-            } else if (selectionHandle1.isVisible() && selectionHandle2.isVisible()) {
-                x -= selectionHandle1.getWidth() / 2;
-                y -= selectionHandle1.getHeight();
-                w += selectionHandle1.getWidth() / 2 + selectionHandle2.getWidth() / 2;
-                h += selectionHandle1.getHeight() + selectionHandle2.getHeight();
-            }
-        }
-
-        if (w > 0 && h > 0) {
-            scrollBoundsToVisible(new Rectangle2D(x, y, w, h));
-        }
-    }
-
-    private void scrollBoundsToVisible(Rectangle2D bounds) {
-        TextArea textArea = getSkinnable();
-        Bounds viewportBounds = scrollPane.getViewportBounds();
-
-        double viewportWidth = viewportBounds.getWidth();
-        double viewportHeight = viewportBounds.getHeight();
-        double scrollTop = textArea.getScrollTop();
-        double scrollLeft = textArea.getScrollLeft();
-        double slop = 6.0;
-
-        if (bounds.getMinY() < 0) {
-            double y = scrollTop + bounds.getMinY();
-            if (y <= contentView.snappedTopInset()) {
-                y = 0;
-            }
-            textArea.setScrollTop(y);
-        } else if (contentView.snappedTopInset() + bounds.getMaxY() > viewportHeight) {
-            double y = scrollTop + contentView.snappedTopInset() + bounds.getMaxY() - viewportHeight;
-            if (y >= getScrollTopMax() - contentView.snappedBottomInset()) {
-                y = getScrollTopMax();
-            }
-            textArea.setScrollTop(y);
-        }
-
-
-        if (bounds.getMinX() < 0) {
-            double x = scrollLeft + bounds.getMinX() - slop;
-            if (x <= contentView.snappedLeftInset() + slop) {
-                x = 0;
-            }
-            textArea.setScrollLeft(x);
-        } else if (contentView.snappedLeftInset() + bounds.getMaxX() > viewportWidth) {
-            double x = scrollLeft + contentView.snappedLeftInset() + bounds.getMaxX() - viewportWidth + slop;
-            if (x >= getScrollLeftMax() - contentView.snappedRightInset() - slop) {
-                x = getScrollLeftMax();
-            }
-            textArea.setScrollLeft(x);
-        }
-    }
-
-    private void updatePrefViewportWidth() {
-        int columnCount = getSkinnable().getPrefColumnCount();
-        scrollPane.setPrefViewportWidth(columnCount * characterWidth + contentView.snappedLeftInset() + contentView.snappedRightInset());
-        scrollPane.setMinViewportWidth(characterWidth + contentView.snappedLeftInset() + contentView.snappedRightInset());
-    }
-
-    private void updatePrefViewportHeight() {
-        int rowCount = getSkinnable().getPrefRowCount();
-        scrollPane.setPrefViewportHeight(rowCount * lineHeight + contentView.snappedTopInset() + contentView.snappedBottomInset());
-        scrollPane.setMinViewportHeight(lineHeight + contentView.snappedTopInset() + contentView.snappedBottomInset());
-    }
-
-    private void updateFontMetrics() {
-        Text firstParagraph = (Text)paragraphNodes.getChildren().get(0);
-        lineHeight = Utils.getLineHeight(getSkinnable().getFont(),firstParagraph.getBoundsType());
-        characterWidth = fontMetrics.get().computeStringWidth("W");
-    }
-
-    @Override
-    protected void updateHighlightFill() {
-       for (Node node : selectionHighlightGroup.getChildren()) {
-           Path selectionHighlightPath = (Path)node;
-           selectionHighlightPath.setFill(highlightFill.get());
-       }
-    }
-
-//     protected void handleMouseReleasedEvent(MouseEvent event) {
-// //        super.handleMouseReleasedEvent(event);
-
-//         // Stop the scroll selection timer
-//         scrollSelectionTimeline.stop();
-//         scrollDirection = null;
-
-//         // Select all if the user double-clicked
-//         if (event.getButton() == MouseButton.PRIMARY
-//             && event.getClickCount() == 3) {
-//             // TODO Select the current row
-//         }
-//     }
-
-    // Callbacks from Behavior class
-
-    private double getTextTranslateX() {
-        return contentView.snappedLeftInset();
-    }
-
-    private double getTextTranslateY() {
-        return contentView.snappedTopInset();
-    }
-
-    private double getTextLeft() {
-        return 0;
-    }
-
-    private Point2D translateCaretPosition(Point2D p) {
-        return p;
-    }
-
-    private Text getTextNode() {
-        if (USE_MULTIPLE_NODES) {
-            throw new IllegalArgumentException("Multiple node traversal is not yet implemented.");
-        }
-        return (Text)paragraphNodes.getChildren().get(0);
-    }
-
-    public HitInfo getIndex(double x, double y) {
-        // adjust the event to be in the same coordinate space as the
-        // text content of the textInputControl
-        Text textNode = getTextNode();
-        Point2D p = new Point2D(x - textNode.getLayoutX(), y - getTextTranslateY());
-        HitInfo hit = textNode.impl_hitTestChar(translateCaretPosition(p));
-        int pos = hit.getCharIndex();
-        if (pos > 0) {
-            int oldPos = textNode.getImpl_caretPosition();
-            textNode.setImpl_caretPosition(pos);
-            PathElement element = textNode.getImpl_caretShape()[0];
-            if (element instanceof MoveTo && ((MoveTo)element).getY() > y - getTextTranslateY()) {
-                hit.setCharIndex(pos - 1);
-            }
-            textNode.setImpl_caretPosition(oldPos);
-        }
-        return hit;
-    };
-
-    /**
-     * Remembers horizontal position when traversing up / down.
-     */
-    double targetCaretX = -1;
-
-    @Override public void nextCharacterVisually(boolean moveRight) {
-        if (isRTL()) {
-            // Text node is mirrored.
-            moveRight = !moveRight;
-        }
-
-        Text textNode = getTextNode();
-        Bounds caretBounds = caretPath.getLayoutBounds();
-        if (caretPath.getElements().size() == 4) {
-            // The caret is split
-            // TODO: Find a better way to get the primary caret position
-            // instead of depending on the internal implementation.
-            // See RT-25465.
-            caretBounds = new Path(caretPath.getElements().get(0), caretPath.getElements().get(1)).getLayoutBounds();
-        }
-        double hitX = moveRight ? caretBounds.getMaxX() : caretBounds.getMinX();
-        double hitY = (caretBounds.getMinY() + caretBounds.getMaxY()) / 2;
-        HitInfo hit = textNode.impl_hitTestChar(new Point2D(hitX, hitY));
-        Path charShape = new Path(textNode.impl_getRangeShape(hit.getCharIndex(), hit.getCharIndex() + 1));
-        if ((moveRight && charShape.getLayoutBounds().getMaxX() > caretBounds.getMaxX()) ||
-            (!moveRight && charShape.getLayoutBounds().getMinX() < caretBounds.getMinX())) {
-            hit.setLeading(!hit.isLeading());
-            positionCaret(hit, false, false);
-        } else {
-            // We're at beginning or end of line. Try moving up / down.
-            int dot = textArea.getCaretPosition();
-            targetCaretX = moveRight ? 0 : Double.MAX_VALUE;
-            // TODO: Use Bidi sniffing instead of assuming right means forward here?
-            downLines(moveRight ? 1 : -1, false, false);
-            targetCaretX = -1;
-            if (dot == textArea.getCaretPosition()) {
-                if (moveRight) {
-                    textArea.forward();
-                } else {
-                    textArea.backward();
-                }
-            }
-        }
-    }
-
-    /** A shared helper object, used only by downLines(). */
-    private static final Path tmpCaretPath = new Path();
-
-    protected void downLines(int nLines, boolean select, boolean extendSelection) {
-        Text textNode = getTextNode();
-        Bounds caretBounds = caretPath.getLayoutBounds();
-
-        // The middle y coordinate of the the line we want to go to.
-        double targetLineMidY = (caretBounds.getMinY() + caretBounds.getMaxY()) / 2 + nLines * lineHeight;
-        if (targetLineMidY < 0) {
-            targetLineMidY = 0;
-        }
-
-        // The target x for the caret. This may have been set during a
-        // previous call.
-        double x = (targetCaretX >= 0) ? targetCaretX : (caretBounds.getMaxX());
-
-        // Find a text position for the target x,y.
-        HitInfo hit = textNode.impl_hitTestChar(translateCaretPosition(new Point2D(x, targetLineMidY)));
-        int pos = hit.getCharIndex();
-
-        // Save the old pos temporarily while testing the new one.
-        int oldPos = textNode.getImpl_caretPosition();
-        boolean oldBias = textNode.isImpl_caretBias();
-        textNode.setImpl_caretBias(hit.isLeading());
-        textNode.setImpl_caretPosition(pos);
-        tmpCaretPath.getElements().clear();
-        tmpCaretPath.getElements().addAll(textNode.getImpl_caretShape());
-        tmpCaretPath.setLayoutX(textNode.getLayoutX());
-        tmpCaretPath.setLayoutY(textNode.getLayoutY());
-        Bounds tmpCaretBounds = tmpCaretPath.getLayoutBounds();
-        // The y for the middle of the row we found.
-        double foundLineMidY = (tmpCaretBounds.getMinY() + tmpCaretBounds.getMaxY()) / 2;
-        textNode.setImpl_caretBias(oldBias);
-        textNode.setImpl_caretPosition(oldPos);
-
-        if (pos > 0) {
-            if (nLines > 0 && foundLineMidY > targetLineMidY) {
-                // We went too far and ended up after a newline.
-                hit.setCharIndex(pos - 1);
-            }
-
-            if (pos >= textArea.getLength() && getCharacter(pos - 1) == '\n') {
-                // Special case for newline at end of text.
-                hit.setLeading(true);
-            }
-        }
-
-        // Test if the found line is in the correct direction and move
-        // the caret.
-        if (nLines == 0 ||
-            (nLines > 0 && foundLineMidY > caretBounds.getMaxY()) ||
-            (nLines < 0 && foundLineMidY < caretBounds.getMinY())) {
-
-            positionCaret(hit, select, extendSelection);
-            targetCaretX = x;
-        }
-    }
-
-    public void previousLine(boolean select) {
-        downLines(-1, select, false);
-    }
-
-    public void nextLine(boolean select) {
-        downLines(1, select, false);
-    }
-
-    public void previousPage(boolean select) {
-        downLines(-(int)(scrollPane.getViewportBounds().getHeight() / lineHeight),
-                  select, false);
-    }
-
-    public void nextPage(boolean select) {
-        downLines((int)(scrollPane.getViewportBounds().getHeight() / lineHeight),
-                  select, false);
-    }
-
-    public void lineStart(boolean select, boolean extendSelection) {
-        targetCaretX = 0;
-        downLines(0, select, extendSelection);
-        targetCaretX = -1;
-    }
-
-    public void lineEnd(boolean select, boolean extendSelection) {
-        targetCaretX = Double.MAX_VALUE;
-        downLines(0, select, extendSelection);
-        targetCaretX = -1;
-    }
-
-
-    public void paragraphStart(boolean previousIfAtStart, boolean select) {
-        TextArea textArea = getSkinnable();
-        String text = textArea.textProperty().getValueSafe();
-        int pos = textArea.getCaretPosition();
+        String text = textArea.textProperty().getValueSafe();
+        int pos = textArea.getCaretPosition();
 
         if (pos > 0) {
             if (previousIfAtStart && text.codePointAt(pos-1) == 0x0a) {
                 // We are at the beginning of a paragraph.
                 // Back up to the previous paragraph.

@@ -1242,16 +748,17 @@
                 textArea.positionCaret(pos);
             }
         }
     }
 
-    public void paragraphEnd(boolean goPastInitialNewline, boolean goPastTrailingNewline, boolean select) {
+    private void paragraphEnd(boolean goPastInitialNewline, boolean select) {
         TextArea textArea = getSkinnable();
         String text = textArea.textProperty().getValueSafe();
         int pos = textArea.getCaretPosition();
         int len = text.length();
         boolean wentPastInitialNewline = false;
+        boolean goPastTrailingNewline = isWindows();
 
         if (pos < len) {
             if (goPastInitialNewline && text.codePointAt(pos) == 0x0a) {
                 // We are at the end of a paragraph, start by moving to the
                 // next paragraph.

@@ -1275,20 +782,11 @@
                 textArea.positionCaret(pos);
             }
         }
     }
 
-    private void updateTextNodeCaretPos(int pos) {
-        Text textNode = getTextNode();
-        if (isForwardBias()) {
-            textNode.setImpl_caretPosition(pos);
-        } else {
-            textNode.setImpl_caretPosition(pos - 1);
-        }
-        textNode.impl_caretBiasProperty().set(isForwardBias());
-    }
-
+    /** {@inheritDoc} */
     @Override protected PathElement[] getUnderlineShape(int start, int end) {
         int pStart = 0;
         for (Node node : paragraphNodes.getChildren()) {
             Text p = (Text)node;
             int pEnd = pStart + p.textProperty().getValueSafe().length();

@@ -1298,10 +796,11 @@
             pStart = pEnd + 1;
         }
         return null;
     }
 
+    /** {@inheritDoc} */
     @Override protected PathElement[] getRangeShape(int start, int end) {
         int pStart = 0;
         for (Node node : paragraphNodes.getChildren()) {
             Text p = (Text)node;
             int pEnd = pStart + p.textProperty().getValueSafe().length();

@@ -1311,10 +810,11 @@
             pStart = pEnd + 1;
         }
         return null;
     }
 
+    /** {@inheritDoc} */
     @Override protected void addHighlight(List<? extends Node> nodes, int start) {
         int pStart = 0;
         Text paragraphNode = null;
         for (Node node : paragraphNodes.getChildren()) {
             Text p = (Text)node;

@@ -1333,49 +833,36 @@
             }
         }
         contentView.getChildren().addAll(nodes);
     }
 
+    /** {@inheritDoc} */
     @Override protected void removeHighlight(List<? extends Node> nodes) {
         contentView.getChildren().removeAll(nodes);
     }
 
-    /**
-     * Use this implementation instead of the one provided on TextInputControl
-     * Simply calls into TextInputControl.deletePrevious/NextChar and responds appropriately
-     * based on the return value.
-     */
-    public void deleteChar(boolean previous) {
-//        final double textMaxXOld = textNode.getBoundsInParent().getMaxX();
-//        final double caretMaxXOld = caretPath.getLayoutBounds().getMaxX() + textTranslateX.get();
-        final boolean shouldBeep = previous ?
-                !getSkinnable().deletePreviousChar() :
-                !getSkinnable().deleteNextChar();
-
-        if (shouldBeep) {
-//            beep();
-        } else {
-//            scrollAfterDelete(textMaxXOld, caretMaxXOld);
-        }
-    }
-
+    /** {@inheritDoc} */
     @Override public Point2D getMenuPosition() {
         contentView.layoutChildren();
         Point2D p = super.getMenuPosition();
         if (p != null) {
             p = new Point2D(Math.max(0, p.getX() - contentView.snappedLeftInset() - getSkinnable().getScrollLeft()),
                             Math.max(0, p.getY() - contentView.snappedTopInset() - getSkinnable().getScrollTop()));
         }
         return p;
     }
 
+    // Public for FXVKSkin
+    /**
+     * @return the {@code Bounds} of the caret shape, relative to the {@code TextArea}.
+     */
     public Bounds getCaretBounds() {
         return getSkinnable().sceneToLocal(caretPath.localToScene(caretPath.getBoundsInLocal()));
     }
 
-    @Override
-    protected Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) {
+    /** {@inheritDoc} */
+    @Override protected Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) {
         switch (attribute) {
             case LINE_FOR_OFFSET:
             case LINE_START:
             case LINE_END:
             case BOUNDS_FOR_RANGE:

@@ -1383,6 +870,660 @@
                 Text text = getTextNode();
                 return text.queryAccessibleAttribute(attribute, parameters);
             default: return super.queryAccessibleAttribute(attribute, parameters);
         }
     }
+
+    /** {@inheritDoc} */
+    @Override public void dispose() {
+        super.dispose();
+
+        if (behavior != null) {
+            behavior.dispose();
+        }
+
+        // TODO Unregister listeners on text editor, paragraph list
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    @Override public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) {
+        Text firstParagraph = (Text) paragraphNodes.getChildren().get(0);
+        return Utils.getAscent(getSkinnable().getFont(), firstParagraph.getBoundsType())
+                + contentView.snappedTopInset() + textArea.snappedTopInset();
+    }
+
+    private char getCharacter(int index) {
+        int n = paragraphNodes.getChildren().size();
+
+        int paragraphIndex = 0;
+        int offset = index;
+
+        String paragraph = null;
+        while (paragraphIndex < n) {
+            Text paragraphNode = (Text)paragraphNodes.getChildren().get(paragraphIndex);
+            paragraph = paragraphNode.getText();
+            int count = paragraph.length() + 1;
+
+            if (offset < count) {
+                break;
+            }
+
+            offset -= count;
+            paragraphIndex++;
+        }
+
+        return offset == paragraph.length() ? '\n' : paragraph.charAt(offset);
+    }
+
+    /** {@inheritDoc} */
+    @Override protected int getInsertionPoint(double x, double y) {
+        TextArea textArea = getSkinnable();
+
+        int n = paragraphNodes.getChildren().size();
+        int index = -1;
+
+        if (n > 0) {
+            if (y < contentView.snappedTopInset()) {
+                // Select the character at x in the first row
+                Text paragraphNode = (Text)paragraphNodes.getChildren().get(0);
+                index = getNextInsertionPoint(paragraphNode, x, -1, VerticalDirection.DOWN);
+            } else if (y > contentView.snappedTopInset() + contentView.getHeight()) {
+                // Select the character at x in the last row
+                int lastParagraphIndex = n - 1;
+                Text lastParagraphView = (Text)paragraphNodes.getChildren().get(lastParagraphIndex);
+
+                index = getNextInsertionPoint(lastParagraphView, x, -1, VerticalDirection.UP)
+                        + (textArea.getLength() - lastParagraphView.getText().length());
+            } else {
+                // Select the character at x in the row at y
+                int paragraphOffset = 0;
+                for (int i = 0; i < n; i++) {
+                    Text paragraphNode = (Text)paragraphNodes.getChildren().get(i);
+
+                    Bounds bounds = paragraphNode.getBoundsInLocal();
+                    double paragraphViewY = paragraphNode.getLayoutY() + bounds.getMinY();
+                    if (y >= paragraphViewY
+                            && y < paragraphViewY + paragraphNode.getBoundsInLocal().getHeight()) {
+                        index = getInsertionPoint(paragraphNode,
+                                x - paragraphNode.getLayoutX(),
+                                y - paragraphNode.getLayoutY()) + paragraphOffset;
+                        break;
+                    }
+
+                    paragraphOffset += paragraphNode.getText().length() + 1;
+                }
+            }
+        }
+
+        return index;
+    }
+
+    // Public for behavior
+    /**
+     * Moves the caret to the specified position.
+     *
+     * @param hit the new position and forward bias of the caret.
+     * @param select whether to extend selection to the new position.
+     */
+    public void positionCaret(TextPosInfo hit, boolean select) {
+        positionCaret(hit, select, false);
+    }
+
+    private void positionCaret(TextPosInfo hit, boolean select, boolean extendSelection) {
+        int pos = Utils.getHitInsertionIndex(hit, getSkinnable().getText());
+        boolean isNewLine =
+                (pos > 0 &&
+                        pos <= getSkinnable().getLength() &&
+                        getSkinnable().getText().codePointAt(pos-1) == 0x0a);
+
+        // special handling for a new line
+        if (!hit.isLeading() && isNewLine) {
+            hit.setLeading(true);
+            pos -= 1;
+        }
+
+        if (select) {
+            if (extendSelection) {
+                getSkinnable().extendSelection(pos);
+            } else {
+                getSkinnable().selectPositionCaret(pos);
+            }
+        } else {
+            getSkinnable().positionCaret(pos);
+        }
+
+        setForwardBias(hit.isLeading());
+    }
+
+    private void positionCaret(HitInfo hit, boolean select) {
+        positionCaret(new TextPosInfo(hit), select);
+    }
+
+    private void positionCaret(HitInfo hit, boolean select, boolean extendSelection) {
+        positionCaret(new TextPosInfo(hit), select, extendSelection);
+    }
+
+    /** {@inheritDoc} */
+    @Override public Rectangle2D getCharacterBounds(int index) {
+        TextArea textArea = getSkinnable();
+
+        int paragraphIndex = paragraphNodes.getChildren().size();
+        int paragraphOffset = textArea.getLength() + 1;
+
+        Text paragraphNode = null;
+        do {
+            paragraphNode = (Text)paragraphNodes.getChildren().get(--paragraphIndex);
+            paragraphOffset -= paragraphNode.getText().length() + 1;
+        } while (index < paragraphOffset);
+
+        int characterIndex = index - paragraphOffset;
+        boolean terminator = false;
+
+        if (characterIndex == paragraphNode.getText().length()) {
+            characterIndex--;
+            terminator = true;
+        }
+
+        characterBoundingPath.getElements().clear();
+        characterBoundingPath.getElements().addAll(paragraphNode.impl_getRangeShape(characterIndex, characterIndex + 1));
+        characterBoundingPath.setLayoutX(paragraphNode.getLayoutX());
+        characterBoundingPath.setLayoutY(paragraphNode.getLayoutY());
+
+        Bounds bounds = characterBoundingPath.getBoundsInLocal();
+
+        double x = bounds.getMinX() + paragraphNode.getLayoutX() - textArea.getScrollLeft();
+        double y = bounds.getMinY() + paragraphNode.getLayoutY() - textArea.getScrollTop();
+
+        // Sometimes the bounds is empty, in which case we must ignore the width/height
+        double width = bounds.isEmpty() ? 0 : bounds.getWidth();
+        double height = bounds.isEmpty() ? 0 : bounds.getHeight();
+
+        if (terminator) {
+            x += width;
+            width = 0;
+        }
+
+        return new Rectangle2D(x, y, width, height);
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void scrollCharacterToVisible(final int index) {
+        // TODO We queue a callback because when characters are added or
+        // removed the bounds are not immediately updated; is this really
+        // necessary?
+
+        Platform.runLater(() -> {
+            if (getSkinnable().getLength() == 0) {
+                return;
+            }
+            Rectangle2D characterBounds = getCharacterBounds(index);
+            scrollBoundsToVisible(characterBounds);
+        });
+    }
+
+
+
+    /**************************************************************************
+     *
+     * Private implementation
+     *
+     **************************************************************************/
+
+    TextAreaBehavior getBehavior() {
+        return behavior;
+    }
+
+    private void createPromptNode() {
+        if (promptNode == null && usePromptText.get()) {
+            promptNode = new Text();
+            contentView.getChildren().add(0, promptNode);
+            promptNode.setManaged(false);
+            promptNode.getStyleClass().add("text");
+            promptNode.visibleProperty().bind(usePromptText);
+            promptNode.fontProperty().bind(getSkinnable().fontProperty());
+            promptNode.textProperty().bind(getSkinnable().promptTextProperty());
+            promptNode.fillProperty().bind(promptTextFillProperty());
+        }
+    }
+
+    private void addParagraphNode(int i, String string) {
+        final TextArea textArea = getSkinnable();
+        Text paragraphNode = new Text(string);
+        paragraphNode.setTextOrigin(VPos.TOP);
+        paragraphNode.setManaged(false);
+        paragraphNode.getStyleClass().add("text");
+        paragraphNode.boundsTypeProperty().addListener((observable, oldValue, newValue) -> {
+            invalidateMetrics();
+            updateFontMetrics();
+        });
+        paragraphNodes.getChildren().add(i, paragraphNode);
+
+        paragraphNode.fontProperty().bind(textArea.fontProperty());
+        paragraphNode.fillProperty().bind(textFillProperty());
+        paragraphNode.impl_selectionFillProperty().bind(highlightTextFillProperty());
+    }
+
+    private double getScrollTopMax() {
+        return Math.max(0, contentView.getHeight() - scrollPane.getViewportBounds().getHeight());
+    }
+
+    private double getScrollLeftMax() {
+        return Math.max(0, contentView.getWidth() - scrollPane.getViewportBounds().getWidth());
+    }
+
+    private int getInsertionPoint(Text paragraphNode, double x, double y) {
+        TextPosInfo hitInfo = new TextPosInfo(paragraphNode.impl_hitTestChar(new Point2D(x, y)));
+        return Utils.getHitInsertionIndex(hitInfo, paragraphNode.getText());
+    }
+
+    private int getNextInsertionPoint(Text paragraphNode, double x, int from,
+        VerticalDirection scrollDirection) {
+        // TODO
+        return 0;
+    }
+
+    private void scrollCaretToVisible() {
+        TextArea textArea = getSkinnable();
+        Bounds bounds = caretPath.getLayoutBounds();
+        double x = bounds.getMinX() - textArea.getScrollLeft();
+        double y = bounds.getMinY() - textArea.getScrollTop();
+        double w = bounds.getWidth();
+        double h = bounds.getHeight();
+
+        if (SHOW_HANDLES) {
+            if (caretHandle.isVisible()) {
+                h += caretHandle.getHeight();
+            } else if (selectionHandle1.isVisible() && selectionHandle2.isVisible()) {
+                x -= selectionHandle1.getWidth() / 2;
+                y -= selectionHandle1.getHeight();
+                w += selectionHandle1.getWidth() / 2 + selectionHandle2.getWidth() / 2;
+                h += selectionHandle1.getHeight() + selectionHandle2.getHeight();
+            }
+        }
+
+        if (w > 0 && h > 0) {
+            scrollBoundsToVisible(new Rectangle2D(x, y, w, h));
+        }
+    }
+
+    private void scrollBoundsToVisible(Rectangle2D bounds) {
+        TextArea textArea = getSkinnable();
+        Bounds viewportBounds = scrollPane.getViewportBounds();
+
+        double viewportWidth = viewportBounds.getWidth();
+        double viewportHeight = viewportBounds.getHeight();
+        double scrollTop = textArea.getScrollTop();
+        double scrollLeft = textArea.getScrollLeft();
+        double slop = 6.0;
+
+        if (bounds.getMinY() < 0) {
+            double y = scrollTop + bounds.getMinY();
+            if (y <= contentView.snappedTopInset()) {
+                y = 0;
+            }
+            textArea.setScrollTop(y);
+        } else if (contentView.snappedTopInset() + bounds.getMaxY() > viewportHeight) {
+            double y = scrollTop + contentView.snappedTopInset() + bounds.getMaxY() - viewportHeight;
+            if (y >= getScrollTopMax() - contentView.snappedBottomInset()) {
+                y = getScrollTopMax();
+            }
+            textArea.setScrollTop(y);
+        }
+
+
+        if (bounds.getMinX() < 0) {
+            double x = scrollLeft + bounds.getMinX() - slop;
+            if (x <= contentView.snappedLeftInset() + slop) {
+                x = 0;
+            }
+            textArea.setScrollLeft(x);
+        } else if (contentView.snappedLeftInset() + bounds.getMaxX() > viewportWidth) {
+            double x = scrollLeft + contentView.snappedLeftInset() + bounds.getMaxX() - viewportWidth + slop;
+            if (x >= getScrollLeftMax() - contentView.snappedRightInset() - slop) {
+                x = getScrollLeftMax();
+            }
+            textArea.setScrollLeft(x);
+        }
+    }
+
+    private void updatePrefViewportWidth() {
+        int columnCount = getSkinnable().getPrefColumnCount();
+        scrollPane.setPrefViewportWidth(columnCount * characterWidth + contentView.snappedLeftInset() + contentView.snappedRightInset());
+        scrollPane.setMinViewportWidth(characterWidth + contentView.snappedLeftInset() + contentView.snappedRightInset());
+    }
+
+    private void updatePrefViewportHeight() {
+        int rowCount = getSkinnable().getPrefRowCount();
+        scrollPane.setPrefViewportHeight(rowCount * lineHeight + contentView.snappedTopInset() + contentView.snappedBottomInset());
+        scrollPane.setMinViewportHeight(lineHeight + contentView.snappedTopInset() + contentView.snappedBottomInset());
+    }
+
+    private void updateFontMetrics() {
+        Text firstParagraph = (Text)paragraphNodes.getChildren().get(0);
+        lineHeight = Utils.getLineHeight(getSkinnable().getFont(), firstParagraph.getBoundsType());
+        characterWidth = fontMetrics.get().computeStringWidth("W");
+    }
+
+    private double getTextTranslateX() {
+        return contentView.snappedLeftInset();
+    }
+
+    private double getTextTranslateY() {
+        return contentView.snappedTopInset();
+    }
+
+    private double getTextLeft() {
+        return 0;
+    }
+
+    private Point2D translateCaretPosition(Point2D p) {
+        return p;
+    }
+
+    private Text getTextNode() {
+        if (USE_MULTIPLE_NODES) {
+            throw new IllegalArgumentException("Multiple node traversal is not yet implemented.");
+        }
+        return (Text)paragraphNodes.getChildren().get(0);
+    }
+
+    private void updateTextNodeCaretPos(int pos) {
+        Text textNode = getTextNode();
+        if (isForwardBias()) {
+            textNode.setImpl_caretPosition(pos);
+        } else {
+            textNode.setImpl_caretPosition(pos - 1);
+        }
+        textNode.impl_caretBiasProperty().set(isForwardBias());
+    }
+
+
+
+    /**************************************************************************
+     *
+     * Support classes
+     *
+     **************************************************************************/
+
+    private class ContentView extends Region {
+        {
+            getStyleClass().add("content");
+
+            addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+                behavior.mousePressed(event);
+                event.consume();
+            });
+
+            addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
+                behavior.mouseReleased(event);
+                event.consume();
+            });
+
+            addEventHandler(MouseEvent.MOUSE_DRAGGED, event -> {
+                behavior.mouseDragged(event);
+                event.consume();
+            });
+        }
+
+        @Override protected ObservableList<Node> getChildren() {
+            return super.getChildren();
+        }
+
+        @Override public Orientation getContentBias() {
+            return Orientation.HORIZONTAL;
+        }
+
+        @Override protected double computePrefWidth(double height) {
+            if (computedPrefWidth < 0) {
+                double prefWidth = 0;
+
+                for (Node node : paragraphNodes.getChildren()) {
+                    Text paragraphNode = (Text)node;
+                    prefWidth = Math.max(prefWidth,
+                            Utils.computeTextWidth(paragraphNode.getFont(),
+                                    paragraphNode.getText(), 0));
+                }
+
+                prefWidth += snappedLeftInset() + snappedRightInset();
+
+                Bounds viewPortBounds = scrollPane.getViewportBounds();
+                computedPrefWidth = Math.max(prefWidth, (viewPortBounds != null) ? viewPortBounds.getWidth() : 0);
+            }
+            return computedPrefWidth;
+        }
+
+        @Override protected double computePrefHeight(double width) {
+            if (width != widthForComputedPrefHeight) {
+                invalidateMetrics();
+                widthForComputedPrefHeight = width;
+            }
+
+            if (computedPrefHeight < 0) {
+                double wrappingWidth;
+                if (width == -1) {
+                    wrappingWidth = 0;
+                } else {
+                    wrappingWidth = Math.max(width - (snappedLeftInset() + snappedRightInset()), 0);
+                }
+
+                double prefHeight = 0;
+
+                for (Node node : paragraphNodes.getChildren()) {
+                    Text paragraphNode = (Text)node;
+                    prefHeight += Utils.computeTextHeight(
+                            paragraphNode.getFont(),
+                            paragraphNode.getText(),
+                            wrappingWidth,
+                            paragraphNode.getBoundsType());
+                }
+
+                prefHeight += snappedTopInset() + snappedBottomInset();
+
+                Bounds viewPortBounds = scrollPane.getViewportBounds();
+                computedPrefHeight = Math.max(prefHeight, (viewPortBounds != null) ? viewPortBounds.getHeight() : 0);
+            }
+            return computedPrefHeight;
+        }
+
+        @Override protected double computeMinWidth(double height) {
+            if (computedMinWidth < 0) {
+                double hInsets = snappedLeftInset() + snappedRightInset();
+                computedMinWidth = Math.min(characterWidth + hInsets, computePrefWidth(height));
+            }
+            return computedMinWidth;
+        }
+
+        @Override protected double computeMinHeight(double width) {
+            if (computedMinHeight < 0) {
+                double vInsets = snappedTopInset() + snappedBottomInset();
+                computedMinHeight = Math.min(lineHeight + vInsets, computePrefHeight(width));
+            }
+            return computedMinHeight;
+        }
+
+        @Override public void layoutChildren() {
+            TextArea textArea = getSkinnable();
+            double width = getWidth();
+
+            // Lay out paragraphs
+            final double topPadding = snappedTopInset();
+            final double leftPadding = snappedLeftInset();
+
+            double wrappingWidth = Math.max(width - (leftPadding + snappedRightInset()), 0);
+
+            double y = topPadding;
+
+            final List<Node> paragraphNodesChildren = paragraphNodes.getChildren();
+
+            for (int i = 0; i < paragraphNodesChildren.size(); i++) {
+                Node node = paragraphNodesChildren.get(i);
+                Text paragraphNode = (Text)node;
+                paragraphNode.setWrappingWidth(wrappingWidth);
+
+                Bounds bounds = paragraphNode.getBoundsInLocal();
+                paragraphNode.setLayoutX(leftPadding);
+                paragraphNode.setLayoutY(y);
+
+                y += bounds.getHeight();
+            }
+
+            if (promptNode != null) {
+                promptNode.setLayoutX(leftPadding);
+                promptNode.setLayoutY(topPadding + promptNode.getBaselineOffset());
+                promptNode.setWrappingWidth(wrappingWidth);
+            }
+
+            // Update the selection
+            IndexRange selection = textArea.getSelection();
+            Bounds oldCaretBounds = caretPath.getBoundsInParent();
+
+            selectionHighlightGroup.getChildren().clear();
+
+            int caretPos = textArea.getCaretPosition();
+            int anchorPos = textArea.getAnchor();
+
+            if (SHOW_HANDLES) {
+                // Install and resize the handles for caret and anchor.
+                if (selection.getLength() > 0) {
+                    selectionHandle1.resize(selectionHandle1.prefWidth(-1),
+                            selectionHandle1.prefHeight(-1));
+                    selectionHandle2.resize(selectionHandle2.prefWidth(-1),
+                            selectionHandle2.prefHeight(-1));
+                } else {
+                    caretHandle.resize(caretHandle.prefWidth(-1),
+                            caretHandle.prefHeight(-1));
+                }
+
+                // Position the handle for the anchor. This could be handle1 or handle2.
+                // Do this before positioning the actual caret.
+                if (selection.getLength() > 0) {
+                    int paragraphIndex = paragraphNodesChildren.size();
+                    int paragraphOffset = textArea.getLength() + 1;
+                    Text paragraphNode = null;
+                    do {
+                        paragraphNode = (Text)paragraphNodesChildren.get(--paragraphIndex);
+                        paragraphOffset -= paragraphNode.getText().length() + 1;
+                    } while (anchorPos < paragraphOffset);
+
+                    updateTextNodeCaretPos(anchorPos - paragraphOffset);
+                    caretPath.getElements().clear();
+                    caretPath.getElements().addAll(paragraphNode.getImpl_caretShape());
+                    caretPath.setLayoutX(paragraphNode.getLayoutX());
+                    caretPath.setLayoutY(paragraphNode.getLayoutY());
+
+                    Bounds b = caretPath.getBoundsInParent();
+                    if (caretPos < anchorPos) {
+                        selectionHandle2.setLayoutX(b.getMinX() - selectionHandle2.getWidth() / 2);
+                        selectionHandle2.setLayoutY(b.getMaxY() - 1);
+                    } else {
+                        selectionHandle1.setLayoutX(b.getMinX() - selectionHandle1.getWidth() / 2);
+                        selectionHandle1.setLayoutY(b.getMinY() - selectionHandle1.getHeight() + 1);
+                    }
+                }
+            }
+
+            {
+                // Position caret
+                int paragraphIndex = paragraphNodesChildren.size();
+                int paragraphOffset = textArea.getLength() + 1;
+
+                Text paragraphNode = null;
+                do {
+                    paragraphNode = (Text)paragraphNodesChildren.get(--paragraphIndex);
+                    paragraphOffset -= paragraphNode.getText().length() + 1;
+                } while (caretPos < paragraphOffset);
+
+                updateTextNodeCaretPos(caretPos - paragraphOffset);
+
+                caretPath.getElements().clear();
+                caretPath.getElements().addAll(paragraphNode.getImpl_caretShape());
+
+                caretPath.setLayoutX(paragraphNode.getLayoutX());
+
+                // TODO: Remove this temporary workaround for RT-27533
+                paragraphNode.setLayoutX(2 * paragraphNode.getLayoutX() - paragraphNode.getBoundsInParent().getMinX());
+
+                caretPath.setLayoutY(paragraphNode.getLayoutY());
+                if (oldCaretBounds == null || !oldCaretBounds.equals(caretPath.getBoundsInParent())) {
+                    scrollCaretToVisible();
+                }
+            }
+
+            // Update selection fg and bg
+            int start = selection.getStart();
+            int end = selection.getEnd();
+            for (int i = 0, max = paragraphNodesChildren.size(); i < max; i++) {
+                Node paragraphNode = paragraphNodesChildren.get(i);
+                Text textNode = (Text)paragraphNode;
+                int paragraphLength = textNode.getText().length() + 1;
+                if (end > start && start < paragraphLength) {
+                    textNode.setImpl_selectionStart(start);
+                    textNode.setImpl_selectionEnd(Math.min(end, paragraphLength));
+
+                    Path selectionHighlightPath = new Path();
+                    selectionHighlightPath.setManaged(false);
+                    selectionHighlightPath.setStroke(null);
+                    PathElement[] selectionShape = textNode.getImpl_selectionShape();
+                    if (selectionShape != null) {
+                        selectionHighlightPath.getElements().addAll(selectionShape);
+                    }
+                    selectionHighlightGroup.getChildren().add(selectionHighlightPath);
+                    selectionHighlightGroup.setVisible(true);
+                    selectionHighlightPath.setLayoutX(textNode.getLayoutX());
+                    selectionHighlightPath.setLayoutY(textNode.getLayoutY());
+                    updateHighlightFill();
+                } else {
+                    textNode.setImpl_selectionStart(-1);
+                    textNode.setImpl_selectionEnd(-1);
+                    selectionHighlightGroup.setVisible(false);
+                }
+                start = Math.max(0, start - paragraphLength);
+                end   = Math.max(0, end   - paragraphLength);
+            }
+
+            if (SHOW_HANDLES) {
+                // Position handle for the caret. This could be handle1 or handle2 when
+                // a selection is active.
+                Bounds b = caretPath.getBoundsInParent();
+                if (selection.getLength() > 0) {
+                    if (caretPos < anchorPos) {
+                        selectionHandle1.setLayoutX(b.getMinX() - selectionHandle1.getWidth() / 2);
+                        selectionHandle1.setLayoutY(b.getMinY() - selectionHandle1.getHeight() + 1);
+                    } else {
+                        selectionHandle2.setLayoutX(b.getMinX() - selectionHandle2.getWidth() / 2);
+                        selectionHandle2.setLayoutY(b.getMaxY() - 1);
+                    }
+                } else {
+                    caretHandle.setLayoutX(b.getMinX() - caretHandle.getWidth() / 2 + 1);
+                    caretHandle.setLayoutY(b.getMaxY());
+                }
+            }
+
+            if (scrollPane.getPrefViewportWidth() == 0
+                    || scrollPane.getPrefViewportHeight() == 0) {
+                updatePrefViewportWidth();
+                updatePrefViewportHeight();
+                if (getParent() != null && scrollPane.getPrefViewportWidth() > 0
+                        || scrollPane.getPrefViewportHeight() > 0) {
+                    // Force layout of viewRect in ScrollPaneSkin
+                    getParent().requestLayout();
+                }
+            }
+
+            // RT-36454: Fit to width/height only if smaller than viewport.
+            // That is, grow to fit but don't shrink to fit.
+            Bounds viewportBounds = scrollPane.getViewportBounds();
+            boolean wasFitToWidth = scrollPane.isFitToWidth();
+            boolean wasFitToHeight = scrollPane.isFitToHeight();
+            boolean setFitToWidth = textArea.isWrapText() || computePrefWidth(-1) <= viewportBounds.getWidth();
+            boolean setFitToHeight = computePrefHeight(width) <= viewportBounds.getHeight();
+            if (wasFitToWidth != setFitToWidth || wasFitToHeight != setFitToHeight) {
+                Platform.runLater(() -> {
+                    scrollPane.setFitToWidth(setFitToWidth);
+                    scrollPane.setFitToHeight(setFitToHeight);
+                });
+                getParent().requestLayout();
+            }
+        }
+    }
 }