Skip to content

Instantly share code, notes, and snippets.

@hjohn
Created November 25, 2023 01:59
Show Gist options
  • Save hjohn/c7b1bf9d4a4770b1b3ae854b20fbaa94 to your computer and use it in GitHub Desktop.
Save hjohn/c7b1bf9d4a4770b1b3ae854b20fbaa94 to your computer and use it in GitHub Desktop.
Behavior Public API Proposal V2

Behavior Public API Proposal V2

Overview

Introduce a new Behavior interface that can be set on a control to replace its current behavior. The new behavior can be fully custom or composed (or later subclassed) from a default behavior. Some default behaviors will be provided as part of this proposal, but not all.

Goals

  • Allow changing the Behavior of a Control without changing its skin
  • Allow changing the Skin of a Control without having to recreate Behavior
  • Provide for easy creation and reuse of behaviors
  • Provide a few public behaviors for existing controls
  • Existing skins require no changes, unless wanting to provide a public behavior
  • Future compatibility with indirection of behavior actions (semantic events)
  • Future compatibility with CSS provided behaviors (-fx-behavior)
  • Leave unused events to bubble up

Non-Goals

  • Providing public behaviors for all existing controls; this will be a step-by-step process
  • Allowing behaviors to be set via CSS, similar to -fx-skin; this may be part of a future extension.
  • Indirection of the behavior's decision and the action taken on control (via Semantic Events, FunctionTags or otherwise)

Motivation

JavaFX has featured skinned controls almost since its inception. However skins currently not only provide the visuals of a control, they also provide its behavior. Reskinning an existing control therefore means not only having to create new visuals, but also having to (re)create its behavior.

This hard coupling between the skin and its behavior also means that making small changes to existing behaviors requires creating a new skin. It's sometimes possible to do this by installing event handlers or filters on the control to effectively block standard behavior, but this quickly becomes unpredictable, and requires the user to know how platform specific behaviors are implemented.

Description

JavaFX has its controls split into three separate parts to make their implementation and customization easier. The Control which takes part in the scene graph and provides the properties and event handling infrastructure. The Skin which provides the visuals, and the internal Behavior which was supposed to convert user interactions into state changes.

Currently, many skins and behaviors have become interwoven, and its unclear where the skin ends, and where the behavior starts. This entanglement makes it hard to change only the visuals of a control, or to change only its behavior. The resulting coupling typically also makes it much harder to test a skin or its behavior in isolation leading to complicated full integration tests that are often brittle.

This proposal provides clear rules for Controls, Skins and Behaviors, and how they are allowed to interact.

Controls

Controls should always give the appearance to users that it is fully under their control. Any modifiable aspect of a Control is considered to be final when set by users, unless changed as the result of an interaction.

There is one exception to this rule; author stylesheets can override properties even when set directly. As author stylesheets are created and set by the user, this still maintains the illusion of having full control.

  • Never modifies its own publicly writable properties
  • Provides the illusion of being under full control by the user that created it
  • Serves as an event target for both low and high level events
  • Allows changing skins and behaviors
  • Tracks changes made by behaviors for guaranteed proper removal
    • Tracking done using a tightly controlled facade which also limits the control surface presented to behaviors to allow for easier future extension

Skins

Skins are designed to work in concert with Control. Skins are allowed to modify the visuals of a control by modifying the control's children list. Control inherits from Region where the children list is not yet modifiable by users, and so this maintains the illusion that a user is in control of all modifiable aspects of a control.

This means that skins never modify a control directly, except for its children. Skins are only allowed to install listeners and call getters on a Control. This is an important rule as skins have full access to their control, and therefore must restrain themselves from taking actions that break the illusion of the user being in full control.

The skin installs children on a control to provide appropriate visuals. Events targeting the skin's children are left to bubble up to Control level as much as possible where they will be accessible by an installed Behavior. The skin is responsible for appropriately tagging the installed children so they can be distinguished by the behavior by examining the event's target. This tagging follows the same publicly known substructure of the skin used by CSS.

Skins should avoid any kind of pre-interpretation or filtering of events as the installed behavior should have access to all events to allow for novel behaviors.

  • Work in concert with a Control, and must maintain the illusion that Control provides despite having full access to all its aspects
  • Only allowed to install listeners on the Control, and to modify the read-only children list
  • Leaves events of its children to bubble for interpretation by an installed Behavior
    • Skins have no direct access to behaviors
    • Relevant AccessibleAction types are forwarded as AccessibleActionEvents
    • Skins should never handle events that have its Control as target

Behaviors

Behaviors are the only part that is allowed to modify a control's state directly. These are always the result of the controls visuals (the skin) being interacted with by the application user. Modifying state that is not the result of an interaction, or has nothing to do with an interaction is not allowed. For example, when the user edits the text in a text field, the text property's state can be modified to reflect this. However, changing visual aspects of the control (like its color, background or borders), unless somehow the result of an interaction, is considered off limits even for behaviors.

  • Works in concert with a Control, and must maintain the illusion that Control provides despite having full access (when handling events) to all its aspects
  • Only allowed to change the Controls state as a result of interactions with the Controls visuals
  • Listens and reacts to events at the Control level
    • Handles both events bubbling up from the Control's children, and events that directly target the control
    • Interpretation of events is dependent on event target (using substructure information)
  • Has no references to either Skin or Control before installation
    • References are received via event handler, key binding handler or property listener
  • Tightly controlled API to allow future extension

Future (Semantic Events)

Semantic events are not a strict requirement to offer Behaviors as reusable components, so in this document they've been left out. They mainly come into play when wishing to provide key binding to function mappings, and there is nothing in this proposal that prevents their addition later.

Semantic events, once added, would be primarily generated by behaviors, converting low level event combinations into higher more meaningful events. Where the best location would be to interpret the events is still unclear, and this could either be in the Control, a second part of the same Behavior class, or in a new separate concept which would have the exclusive role of converting high level events into state changes, to keep interpretation of low level events and high level events separated.

Spinner Conversion Example

As an example we will use the Spinner control. It is fairly small, but has most of the components of the more complicated behaviors.

The existing implementation of SpinnerSkin is aware of its behavior implementation, and makes several decisions that should have been left to the behavior. One such decision is on which mouse button the spinner buttons should act; the skin currently decides this (and it chooses to react on all mouse buttons(!)).

Modifying the existing SpinnerSkin to direct all behavior related decisions to a new behavior implementation is fairly trivial:

  • Remove behavior reference and instantiation
  • Remove mouse handlers on increment-arrow-button and decrement-arrow-button
  • Change executeAccessibleAction to use the new AccessibleActionEvent which the behavior can pick up
  • Slight adjustment to key filter logic that requires knowledge of Spinner orientation
  • Code to stop spinning animation when Scene changes is moved to behavior

The new SpinnerBehavior implementation is a slightly extended version of the current non-public behavior. It deviates in the following areas:

  • Has no knowledge of InputMap but does provide bindings
  • Has methods to detect which skin substructure is involved for handled events
  • Tightly encapsulated, API surface which behaviors need is minimal

SpinnerBehavior implements Behavior<Spinner>. Its install method creates an object to hold some relevant state, and then calls methods on the control specific context to set up the hooks it needs:

@Override
public void install(BehaviorContext<Spinner<?>> context) {
    State state = new State();

    context.addKeyBinding(KeyBinding.of(KeyCode.UP), SpinnerBehavior::arrowsAreVertical, c -> c.increment(1));
    context.addKeyBinding(KeyBinding.of(KeyCode.DOWN), SpinnerBehavior::arrowsAreVertical, c -> c.decrement(1));
    context.addKeyBinding(KeyBinding.of(KeyCode.RIGHT), Predicate.not(SpinnerBehavior::arrowsAreVertical), c -> c.increment(1));
    context.addKeyBinding(KeyBinding.of(KeyCode.LEFT), Predicate.not(SpinnerBehavior::arrowsAreVertical), c -> c.decrement(1));

    context.addEventHandler(MouseEvent.MOUSE_PRESSED, state::mousePressed);
    context.addEventHandler(MouseEvent.MOUSE_RELEASED, state::mouseReleased);
    context.addEventHandler(AccessibleActionEvent.TRIGGERED, state::accessibleActionTriggered);

    context.addPropertyListener(Node::sceneProperty, (scene, c) -> state.stopSpinning());
}

To see how mouse events are handled to react to clicks on the spinner button let's look at the mousePressed method:

void mousePressed(MouseEvent event, Spinner<?> control) {
    if (isIncrementArrow(event)) {
        control.requestFocus();
        startSpinning(control, true);
        event.consume();
    }
    else if (isDecrementArrow(event)) {
        control.requestFocus();
        startSpinning(control, false);
        event.consume();
    }
}

The above method checks if the event is associated with one of the spinner's arrow buttons, and if so, focuses the spinner and starts the spinning animation. Note that the implementation does not check which mouse button was pressed to accurately reflect the old internal behavior, but also note how trivial it would be to ensure it only reacts to the primary mouse button.

A method to determine the arrow type looks like this:

private static boolean isIncrementArrow(Event event) {
    if (!(event.getTarget() instanceof Node n)) {
        return false;
    }

    return n.getStyleClass().contains("increment-arrow-button");
}

The above check is relatively safe, as only events that bubble up from the skin substructure would be checked for these styles. There also is some precedence to check style classes for this determination (see arrowsAreVertical in the old internal behavior), however a more sophisticated API could also be considered (by adding a default method to the Skin interface).

TextAreaBehavior solution

Most existing skins are relatively well separated from their behaviors, and although some things are handled in the skins currently that should be left for behaviors, they are fairly straight forward to convert to make use of a public behavior.

An exception is TextAreaBehavior which has an "unholy" back reference to its skin. This reference is needed as some actions are dependent on the visual layout of the text of which the Control is unaware. A simple example is to move the cursor to the end of the visible line (where it wraps) without moving it to the end of the paragraph (the first line feed encountered). Only the skin has the relevant information.

To still allow behaviors and skins to fairly easily work independently and thus make them easier to replace individually, an interface should be defined between these kinds of skins and behaviors. The behavior will make use of the extra functionality when an appropriate skin is detected, and ignores the extra functions otherwise. This separation makes providing an alternative skin or behavior much more viable as not all functionality is required to work in concert with the existing FX provided behavior or skin counterpart.

To make this work, a public interface is provided which the skin implements; it would have functions like that would find caret positions based on the visible layout (exact details are out of scope for this document). Behavior implementations that offer actions that need visual layout information would do an instanceof check on control.getSkinnable to access the required visual information.

This would be a far cleaner solution than interweaving behaviors and skins, and is comparable to how controllers in MVC are partially aware of the views capabilities without being tied to a specific view implementation.

BehaviorContext and KeyBindings

To allow for fast look-ups of key bindings, and a more expressive API, BehaviorContext offers a method to add a key binding directly, in addition to the standard option of adding any event handler:

public interface BehaviorContext<C extends Control> {
    <T extends Event> void addEventHandler(EventType<T> eventType, BiConsumer<? super T, C> eventHandler);
    <T> void addPropertyListener(Function<C, ObservableValue<T>> supplier, BiConsumer<T, C> listener);

    void addKeyBinding(KeyBinding keyBinding, Consumer<C> eventHandler);
    void addKeyBinding(KeyBinding keyBinding, Predicate<C> condition, Consumer<C> eventHandler);
}

The addKeyBinding method will take a KeyBinding, and call the given event handler when the given Predicate matches. Only a matching event will be automatically consumed, and so using the predicate to prevent consumption of unused events is crucial.

The implementation of a BehaviorContext is reusable for all controls, and is where the key binding system is (for now) implemented, by installing an additional event handler (if needed) for the KEY_PRESSED event; no provisions are made for the other KeyEvents, as they are much more rare and can be handled with a regular event handler.

@Override
public void addKeyBinding(KeyBinding keyBinding, Predicate<C> condition, Consumer<C> eventHandler) {
    Objects.requireNonNull(keyBinding, "keyBinding");
    Objects.requireNonNull(condition, "condition");
    Objects.requireNonNull(eventHandler, "eventHandler");

    if (keyEventHandler == null) {
        keyEventHandler = new KeyEventHandler();

        addEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler);
    }

    keyEventHandler.addBinding(keyBinding, condition, eventHandler);
}

The mappings are stored in a structure that is keyed on key code alone, as the modifier keys have special matching logic (IGNORE, PRESSED, RELEASED) prohibiting the use of KeyBinding as key.

KeyBinding and Mapping are (re)defined as:

public record KeyBinding(KeyCode code, State shift, State ctrl, State alt, State meta) {
    public enum State {
        RELEASED,
        PRESSED,
        IGNORED
    }

    public static KeyBinding of(KeyCode code) {
        return new KeyBinding(code, State.RELEASED, State.RELEASED, State.RELEASED, State.RELEASED);
    }
}
private record Mapping<C>(
    KeyBinding keyBinding, 
    Predicate<C> condition,
    Consumer<C> handler
) {}

And the mappings are stored in the structure: Map<KeyCode, List<Mapping<C>>> mappingsByKeyCode

Problems in old system

com.sun.javafx.scene.control.inputmap.InputMap

The internally used InputMap is part of the reason why many events don't bubble up properly. This is because key bindings will consume their event by default, necessitating workarounds that for example will call the focus traversal system if the key binding turned out to not be needed:

new KeyMapping(UP, KeyEvent.KEY_PRESSED, e -> {
    if (arrowsAreVertical()) increment(1); 
    else FocusTraversalInputMap.traverseUp(e);
})

When reimplementing the behavior these workarounds are unnecessary. Navigation works correctly for the new SpinnerBehavior without ever having to interact with the focus traversal system, simply by NOT consuming events when not needed.

com.sun.javafx.scene.control.inputmap.KeyBinding

The legacy KeyBinding has dangerous equals and hashCode implementations which conflict with each other, making it possible to create key bindings that will never be found if stored in a hash code based structure. equals will allow bindings which do not care for the exact state of a modifier key to be equal with one that has the same key code but with the modifier key in either pressed or released state. As this is not reflected in hashCode, it's quite possible for a hash code based map implementation to not find such a binding when its near equal is assigned to a different bucket.

There is also the concept of specificity for KeyBindings which make bindings that specify exact state for modifiers more important than ones that don't. This seems needlessly complicated when the order of bindings and event handlers should be sufficient to ensure more relevant bindings are acted upon first.

Sample Implementation

SpinnerBehavior

package javafx.scene.control.behavior;

import java.util.List;
import java.util.function.Predicate;

import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.event.Event;
import javafx.scene.AccessibleAction;
import javafx.scene.Node;
import javafx.scene.control.Spinner;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseEvent;
import javafx.util.Duration;

public class SpinnerBehavior implements Behavior<Spinner<?>> {

    @Override
    public void install(BehaviorContext<Spinner<?>> context) {
        State state = new State();

        context.addKeyBinding(KeyBinding.of(KeyCode.UP), SpinnerBehavior::arrowsAreVertical, c -> c.increment(1));
        context.addKeyBinding(KeyBinding.of(KeyCode.DOWN), SpinnerBehavior::arrowsAreVertical, c -> c.decrement(1));
        context.addKeyBinding(KeyBinding.of(KeyCode.RIGHT), Predicate.not(SpinnerBehavior::arrowsAreVertical), c -> c.increment(1));
        context.addKeyBinding(KeyBinding.of(KeyCode.LEFT), Predicate.not(SpinnerBehavior::arrowsAreVertical), c -> c.decrement(1));
        context.addEventHandler(MouseEvent.MOUSE_PRESSED, state::mousePressed);
        context.addEventHandler(MouseEvent.MOUSE_RELEASED, state::mouseReleased);
        context.addEventHandler(AccessibleActionEvent.TRIGGERED, state::accessibleActionTriggered);
        context.addPropertyListener(Node::sceneProperty, (scene, c) -> state.stopSpinning());
    }

    private class State {
        private boolean isIncrementing;
        private Timeline timeline;

        void mousePressed(MouseEvent event, Spinner<?> control) {
            if (isIncrementArrow(event)) {
                control.requestFocus();
                startSpinning(control, true);
                event.consume();
            }
            else if (isDecrementArrow(event)) {
                control.requestFocus();
                startSpinning(control, false);
                event.consume();
            }
        }

        void mouseReleased(MouseEvent event, Spinner<?> control) {
            if (isIncrementArrow(event) || isDecrementArrow(event)) {
                stopSpinning();
                event.consume();
            }
        }

        void accessibleActionTriggered(AccessibleActionEvent event, Spinner<?> control) {
            if (event.getAction() == AccessibleAction.FIRE) {
                if (isIncrementArrow(event)) {
                    control.increment(1);
                    event.consume();
                }
                else if (isDecrementArrow(event)) {
                    control.decrement(1);
                    event.consume();
                }
            }
        }

        void startSpinning(Spinner<?> control, boolean increment) {
            isIncrementing = increment;

            if (timeline != null) {
                timeline.stop();
            }

            KeyFrame start = new KeyFrame(Duration.ZERO, e -> step(control));
            KeyFrame repeat = new KeyFrame(control.getRepeatDelay());

            timeline = new Timeline();
            timeline.setCycleCount(Animation.INDEFINITE);
            timeline.setDelay(control.getInitialDelay());
            timeline.getKeyFrames().setAll(start, repeat);
            timeline.playFromStart();

            step(control);
        }

        void stopSpinning() {
            if (timeline != null) {
                timeline.stop();
                timeline = null;
            }
        }

        void step(Spinner<?> control) {
            if (control.getValueFactory() == null) {
                return;
            }

            if (isIncrementing) {
                control.increment(1);
            }
            else {
                control.decrement(1);
            }
        }
    }

    private static boolean isIncrementArrow(Event event) {
        if (!(event.getTarget() instanceof Node n)) {
            return false;
        }

        return n.getStyleClass().contains("increment-arrow-button");
    }

    private static boolean isDecrementArrow(Event event) {
        if (!(event.getTarget() instanceof Node n)) {
            return false;
        }

        return n.getStyleClass().contains("decrement-arrow-button");
    }

    private static boolean arrowsAreVertical(Spinner<?> control) {
        List<String> styleClasses = control.getStyleClass();

        return !(
            styleClasses.contains(Spinner.STYLE_CLASS_ARROWS_ON_LEFT_HORIZONTAL)
                || styleClasses.contains(Spinner.STYLE_CLASS_ARROWS_ON_RIGHT_HORIZONTAL)
                || styleClasses.contains(Spinner.STYLE_CLASS_SPLIT_ARROWS_HORIZONTAL)
        );
    }
}

StandardBehaviorContext

package javafx.scene.control;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;

import javafx.beans.value.ObservableValue;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.scene.control.behavior.BehaviorContext;
import javafx.scene.control.behavior.KeyBinding;
import javafx.scene.control.behavior.KeyBinding.State;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.util.Subscription;

public class StandardBehaviorContext<C extends Control> implements BehaviorContext<C> {
    private final C control;

    private KeyEventHandler keyEventHandler;
    private Subscription subscription = Subscription.EMPTY;

    public StandardBehaviorContext(C control) {
        this.control = control;
    }

    @Override
    public <T extends Event> void addEventHandler(EventType<T> eventType, BiConsumer<? super T, C> eventHandler) {
        EventHandler<T> handler = e -> eventHandler.accept(e, control);

        control.addEventHandler(eventType, handler);

        subscription = subscription.and(() -> control.removeEventHandler(eventType, handler));
    }

    @Override
    public <T> void addPropertyListener(Function<C, ObservableValue<T>> supplier, BiConsumer<T, C> listener) {
        subscription = subscription.and(supplier.apply(control).subscribe(value -> listener.accept(value, control)));
    }

    @Override
    public void addKeyBinding(KeyBinding keyBinding, Predicate<C> condition, Consumer<C> eventHandler) {
        Objects.requireNonNull(keyBinding, "keyBinding");
        Objects.requireNonNull(condition, "condition");
        Objects.requireNonNull(eventHandler, "eventHandler");

        if (keyEventHandler == null) {
            keyEventHandler = new KeyEventHandler();

            addEventHandler(KeyEvent.KEY_PRESSED, keyEventHandler);
        }

        keyEventHandler.addBinding(keyBinding, condition, eventHandler);
    }

    @Override
    public void addKeyBinding(KeyBinding keyBinding, Consumer<C> eventHandler) {
        addKeyBinding(keyBinding, c -> true, eventHandler);
    }

    public Subscription getSubscription() {
        return subscription;
    }

    private class KeyEventHandler implements BiConsumer<KeyEvent, C> {
        private final Map<KeyCode, List<Mapping<C>>> mappingsByKeyCode = new HashMap<>();

        @Override
        public void accept(KeyEvent event, C control) {
            List<Mapping<C>> mappings = mappingsByKeyCode.get(event.getCode());

            for (Mapping<C> mapping : mappings) {
                if (matches(mapping.keyBinding, event) && mapping.condition.test(control)) {
                    mapping.handler.accept(control);
                    event.consume();
                    break;
                }
            }
        }

        void addBinding(KeyBinding keyBinding, Predicate<C> condition, Consumer<C> eventHandler) {
            mappingsByKeyCode.computeIfAbsent(keyBinding.code(), k -> new ArrayList<>())
                .add(new Mapping<>(keyBinding, condition, eventHandler));
        }
    }

    private record Mapping<C>(KeyBinding keyBinding, Predicate<C> condition, Consumer<C> handler) {}

    private static boolean matches(KeyBinding binding, KeyEvent event) {
        return event.getCode() == binding.code()
            && (binding.shift() == State.IGNORED || (event.isShiftDown() ? binding.shift() == State.PRESSED : binding.shift() == State.RELEASED))
            && (binding.ctrl() == State.IGNORED || (event.isControlDown() ? binding.ctrl() == State.PRESSED : binding.ctrl() == State.RELEASED))
            && (binding.alt() == State.IGNORED || (event.isAltDown() ? binding.alt() == State.PRESSED : binding.alt() == State.RELEASED))
            && (binding.meta() == State.IGNORED || (event.isMetaDown() ? binding.meta() == State.PRESSED : binding.meta() == State.RELEASED))
        ;
    }
}

SpinnerSkin (commented out unnecessary code)

package javafx.scene.control.skin;

import java.util.List;

import javafx.css.PseudoClass;
import javafx.geometry.HPos;
import javafx.geometry.VPos;
import javafx.scene.AccessibleAction;
import javafx.scene.AccessibleRole;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Spinner;
import javafx.scene.control.TextField;
import javafx.scene.control.behavior.AccessibleActionEvent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;

import com.sun.javafx.scene.ParentHelper;
import com.sun.javafx.scene.control.FakeFocusTextField;
import com.sun.javafx.scene.control.ListenerHelper;
import com.sun.javafx.scene.control.behavior.SpinnerBehavior;
import com.sun.javafx.scene.traversal.Algorithm;
import com.sun.javafx.scene.traversal.Direction;
import com.sun.javafx.scene.traversal.ParentTraversalEngine;
import com.sun.javafx.scene.traversal.TraversalContext;

/**
 * Default skin implementation for the {@link Spinner} control.
 *
 * @see Spinner
 * @since 9
 */
public class SpinnerSkin<T> extends SkinBase<Spinner<T>> {

    /* *************************************************************************
     *                                                                         *
     * Private fields                                                          *
     *                                                                         *
     **************************************************************************/

    private TextField textField;

    private Region incrementArrow;
    private StackPane incrementArrowButton;

    private Region decrementArrow;
    private StackPane decrementArrowButton;

    // rather than create an private enum, lets just use an int, here's the important details:
    private static final int ARROWS_ON_RIGHT_VERTICAL   = 0;
    private static final int ARROWS_ON_LEFT_VERTICAL    = 1;
    private static final int ARROWS_ON_RIGHT_HORIZONTAL = 2;
    private static final int ARROWS_ON_LEFT_HORIZONTAL  = 3;
    private static final int SPLIT_ARROWS_VERTICAL      = 4;
    private static final int SPLIT_ARROWS_HORIZONTAL    = 5;

    private int layoutMode = 0;
    /* Package-private for testing purposes */
//    final SpinnerBehavior behavior;


    /* *************************************************************************
     *                                                                         *
     * Constructors                                                            *
     *                                                                         *
     **************************************************************************/

    /**
     * Creates a new SpinnerSkin 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 SpinnerSkin(Spinner<T> control) {
        super(control);

        // install default input map for the Button control
        //behavior = new SpinnerBehavior<>(control);

        textField = control.getEditor();

        ListenerHelper lh = ListenerHelper.get(this);

        updateStyleClass();
        lh.addListChangeListener(control.getStyleClass(), (ch) -> {
            updateStyleClass();
        });

        // increment / decrement arrows
        incrementArrow = new Region();
        incrementArrow.setFocusTraversable(false);
        incrementArrow.getStyleClass().setAll("increment-arrow");
        incrementArrow.setMaxWidth(Region.USE_PREF_SIZE);
        incrementArrow.setMaxHeight(Region.USE_PREF_SIZE);
        incrementArrow.setMouseTransparent(true);

        incrementArrowButton = new StackPane() {
            @Override
            public void executeAccessibleAction(AccessibleAction action, Object... parameters) {
                switch (action) {
                    case FIRE:
                        //getSkinnable().increment();
                        fireEvent(AccessibleActionEvent.triggered(action));
                        break;
                    default: super.executeAccessibleAction(action, parameters);
                }
            }
        };
        incrementArrowButton.setAccessibleRole(AccessibleRole.INCREMENT_BUTTON);
        incrementArrowButton.setFocusTraversable(false);
        incrementArrowButton.getStyleClass().setAll("increment-arrow-button");
        incrementArrowButton.getChildren().add(incrementArrow);
//        incrementArrowButton.setOnMousePressed(e -> {
//            getSkinnable().requestFocus();
//            behavior.startSpinning(true);
//        });
//        incrementArrowButton.setOnMouseReleased(e -> behavior.stopSpinning());

        decrementArrow = new Region();
        decrementArrow.setFocusTraversable(false);
        decrementArrow.getStyleClass().setAll("decrement-arrow");
        decrementArrow.setMaxWidth(Region.USE_PREF_SIZE);
        decrementArrow.setMaxHeight(Region.USE_PREF_SIZE);
        decrementArrow.setMouseTransparent(true);

        decrementArrowButton = new StackPane() {
            @Override
            public void executeAccessibleAction(AccessibleAction action, Object... parameters) {
                switch (action) {
                    case FIRE:
                        //getSkinnable().decrement();
                        fireEvent(AccessibleActionEvent.triggered(action));
                        break;
                    default: super.executeAccessibleAction(action, parameters);
                }
            }
        };
        decrementArrowButton.setAccessibleRole(AccessibleRole.DECREMENT_BUTTON);
        decrementArrowButton.setFocusTraversable(false);
        decrementArrowButton.getStyleClass().setAll("decrement-arrow-button");
        decrementArrowButton.getChildren().add(decrementArrow);
//        decrementArrowButton.setOnMousePressed(e -> {
//            getSkinnable().requestFocus();
//            behavior.startSpinning(false);
//        });
//        decrementArrowButton.setOnMouseReleased(e -> behavior.stopSpinning());

        getChildren().addAll(incrementArrowButton, decrementArrowButton);

        // Fixes in the same vein as ComboBoxListViewSkin

        // move fake focus in to the textfield if the spinner is editable
        lh.addChangeListener(control.focusedProperty(), (op) -> {
            // Fix for the regression noted in a comment in RT-29885.
            ((FakeFocusTextField)textField).setFakeFocus(control.isFocused());
        });

        lh.addEventFilter(control, KeyEvent.ANY, (ke) -> {
            if (control.isEditable()) {
                // This prevents a stack overflow from our rebroadcasting of the
                // event to the textfield that occurs in the final else statement
                // of the conditions below.
                if (ke.getTarget().equals(textField)) return;

                // Fix for RT-38527 which led to a stack overflow
                if (ke.getCode() == KeyCode.ESCAPE) return;

                // This and the additional check of isIncDecKeyEvent in
                // textField's event filter fix JDK-8185937.
                if (isIncDecKeyEvent(ke)) return;

                // Fix for the regression noted in a comment in RT-29885.
                // This forwards the event down into the TextField when
                // the key event is actually received by the Spinner.
                textField.fireEvent(ke.copyFor(textField, textField));

                if (ke.getCode() == KeyCode.ENTER) return;

                ke.consume();
            }
        });

        // This event filter is to enable keyboard events being delivered to the
        // spinner when the user has mouse clicked into the TextField area of the
        // Spinner control. Without this the up/down/left/right arrow keys don't
        // work when you click inside the TextField area (but they do in the case
        // of tabbing in).
        lh.addEventFilter(textField, KeyEvent.ANY, (ke) -> {
            if (! control.isEditable() || isIncDecKeyEvent(ke)) {
                control.fireEvent(ke.copyFor(control, control));
                ke.consume();
            }
        });

        lh.addChangeListener(textField.focusedProperty(), (op) -> {
            boolean hasFocus = textField.isFocused();
            // Fix for RT-29885
            control.getProperties().put("FOCUSED", hasFocus);
            // --- end of RT-29885

            // RT-21454 starts here
            if (! hasFocus) {
                pseudoClassStateChanged(CONTAINS_FOCUS_PSEUDOCLASS_STATE, false);
            } else {
                pseudoClassStateChanged(CONTAINS_FOCUS_PSEUDOCLASS_STATE, true);
            }
            // --- end of RT-21454
        });

        // end of comboBox-esque fixes

        textField.focusTraversableProperty().bind(control.editableProperty());

        // Following code borrowed from ComboBoxPopupControl, to resolve the
        // issue initially identified in RT-36902, but specifically (for Spinner)
        // identified in RT-40625
        ParentHelper.setTraversalEngine(control,
                new ParentTraversalEngine(control, new Algorithm() {

            @Override public Node select(Node owner, Direction dir, TraversalContext context) {
                return null;
            }

            @Override public Node selectFirst(TraversalContext context) {
                return null;
            }

            @Override public Node selectLast(TraversalContext context) {
                return null;
            }
        }));

//        lh.addChangeListener(control.sceneProperty(), (op) -> {
//            // Stop spinning when sceneProperty is modified
//            behavior.stopSpinning();
//        });
    }

    private boolean isIncDecKeyEvent(KeyEvent ke) {
        final KeyCode kc = ke.getCode();
        return (kc == KeyCode.UP || kc == KeyCode.DOWN) && arrowsAreVertical(getSkinnable());
    }

    private static boolean arrowsAreVertical(Spinner<?> control) {
        List<String> styleClasses = control.getStyleClass();

        return !(styleClasses.contains(Spinner.STYLE_CLASS_ARROWS_ON_LEFT_HORIZONTAL)
                || styleClasses.contains(Spinner.STYLE_CLASS_ARROWS_ON_RIGHT_HORIZONTAL)
                || styleClasses.contains(Spinner.STYLE_CLASS_SPLIT_ARROWS_HORIZONTAL));
    }

    /* *************************************************************************
     *                                                                         *
     * Public API                                                              *
     *                                                                         *
     **************************************************************************/

    @Override
    public void install() {
        // when replacing the skin, the textField (which comes from the control), must first be uninstalled
        // by the old skin in its dispose(), followed by (re-)adding it here.
        getChildren().add(textField);
    }

    /** {@inheritDoc} */
    @Override
    public void dispose() {
        if (getSkinnable() == null) {
            return;
        }

        getChildren().removeAll(textField, incrementArrowButton, decrementArrowButton);

//        if (behavior != null) {
//            behavior.dispose();
//        }

        super.dispose();
    }

////////////////////////////////////
// ... Rest unchanged ...
////////////////////////////////////

}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment