All Downloads are FREE. Search and download functionalities are using the official Maven repository.

jfxtras.internal.scene.control.skin.ListSpinnerSkin Maven / Gradle / Ivy

There is a newer version: 17-r1
Show newest version
/**
 * Copyright (c) 2011-2021, JFXtras
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *    Redistributions of source code must retain the above copyright
 *       notice, this list of conditions and the following disclaimer.
 *    Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in the
 *       documentation and/or other materials provided with the distribution.
 *    Neither the name of the organization nor the
 *       names of its contributors may be used to endorse or promote products
 *       derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL JFXTRAS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package jfxtras.internal.scene.control.skin;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.SkinBase;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.RowConstraints;
import javafx.util.Callback;
import javafx.util.Duration;
import jfxtras.animation.Timer;
import jfxtras.css.CssMetaDataForSkinProperty;
import jfxtras.scene.control.ListSpinner;
import jfxtras.scene.layout.HBox;
import jfxtras.scene.layout.VBox;
import jfxtras.util.NodeUtil;

import javafx.css.converter.EnumConverter;

/**
 * 
 * @author Tom Eugelink
 * 
 * Possible extension: drop down list or grid for quick selection
 */
public class ListSpinnerSkin extends SkinBase>
{
	// TODO: vertical centering 
	
	// ==================================================================================================================
	// CONSTRUCTOR
	
	/**
	 * 
	 */
	public ListSpinnerSkin(ListSpinner control)
	{
		super(control);
		construct();
	}

	/*
	 * 
	 */
	private void construct()
	{
            // setup component
            createNodes();

            // react to value changes in the model
            getSkinnable().editableProperty().addListener( (ObservableValue observable, Boolean oldValue, Boolean newValue) -> {
            	replaceValueNode();
            });
            replaceValueNode();

            // react to value changes in the model
            getSkinnable().valueProperty().addListener( (ObservableValue observable, T oldValue, T newValue) -> {
            	refreshValue();
            });
            refreshValue();

            // react to value changes in the model
            setArrowCSS();
            layout();

            // react to value changes in the model
            alignValue();		
	}
	
	/*
	 * 
	 */
	private void refreshValue() 
	{
            // if editable
            if (getSkinnable().isEditable() == true)
            {
                // update textfield
                T lValue = getSkinnable().getValue();
                textField.setText( getSkinnable().getPrefix() + getSkinnable().getStringConverter().toString(lValue) + getSkinnable().getPostfix() );
            }
            else
            {
                // get node for this value
                getSkinnable().getCellFactory().call( getSkinnable() );
            }
	}
	
	// ==================================================================================================================
	// StyleableProperties
	
    /**
     * arrowPosition
     */
    public final ObjectProperty arrowPositionProperty() { return arrowPosition; }
    private ObjectProperty arrowPosition = new SimpleStyleableObjectProperty(StyleableProperties.ARROW_POSITION, this, "arrowPosition", StyleableProperties.ARROW_POSITION.getInitialValue(null)) {
    	{ // anonymous constructor
			addListener( (invalidationEvent) -> {
                setArrowCSS();
                layout();
			});
		}
    };
    public final void setArrowPosition(ArrowPosition value) { arrowPositionProperty().set(value); }
    public final ArrowPosition getArrowPosition() { return arrowPosition.get(); }
    public final ListSpinnerSkin withArrowPosition(ArrowPosition value) { setArrowPosition(value); return this; }
    public enum ArrowPosition {LEADING, TRAILING, SPLIT}
    
    /**
     * arrowDirection
     */
    public final ObjectProperty arrowDirectionProperty() { return arrowDirection; }
    private ObjectProperty arrowDirection = new SimpleStyleableObjectProperty(StyleableProperties.ARROW_DIRECTION, this, "arrowDirection", StyleableProperties.ARROW_DIRECTION.getInitialValue(null)) {
    	{ // anonymous constructor
			addListener( (invalidationEvent) -> {
                setArrowCSS();
                layout();
			});
		}
    };
    public final void setArrowDirection(ArrowDirection value) { arrowDirectionProperty().set(value); }
    public final ArrowDirection getArrowDirection() { return arrowDirection.get(); }
    public final ListSpinnerSkin withArrowDirection(ArrowDirection value) { setArrowDirection(value); return this; }
    public enum ArrowDirection {VERTICAL, HORIZONTAL}
    
    /**
     * valueAlignment
     */
    public final ObjectProperty valueAlignmentProperty() { return valueAlignment; }
    private ObjectProperty valueAlignment = new SimpleStyleableObjectProperty(StyleableProperties.VALUE_ALIGNMENT, this, "valueAlignment", StyleableProperties.VALUE_ALIGNMENT.getInitialValue(null)) {
    	{ // anonymous constructor
			addListener( (invalidationEvent) -> {
                alignValue();
			});
		}
    };
    public final void setValueAlignment(Pos value) { valueAlignmentProperty().set(value); }
    public final Pos getValueAlignment() { return valueAlignment.get(); }
    public final ListSpinnerSkin withValueAlignment(Pos value) { setValueAlignment(value); return this; }

    // -------------------------
        
    private static class StyleableProperties 
    {
        private static final CssMetaData, ArrowPosition> ARROW_POSITION = new CssMetaDataForSkinProperty, ListSpinnerSkin, ArrowPosition>("-fxx-arrow-position", new EnumConverter(ArrowPosition.class), ArrowPosition.TRAILING ) {
        	@Override 
        	protected ObjectProperty getProperty(ListSpinnerSkin s) {
            	return s.arrowPositionProperty();
            }
        };
        
        private static final CssMetaData, ArrowDirection> ARROW_DIRECTION = new CssMetaDataForSkinProperty, ListSpinnerSkin, ArrowDirection>("-fxx-arrow-direction", new EnumConverter(ArrowDirection.class), ArrowDirection.HORIZONTAL ) {
        	@Override 
        	protected ObjectProperty getProperty(ListSpinnerSkin s) {
            	return s.arrowDirectionProperty();
            }
        };
        
        private static final CssMetaData, Pos> VALUE_ALIGNMENT = new CssMetaDataForSkinProperty, ListSpinnerSkin, Pos>("-fxx-value-alignment", new EnumConverter(Pos.class), Pos.CENTER_LEFT ) {
        	@Override 
        	protected ObjectProperty getProperty(ListSpinnerSkin s) {
            	return s.valueAlignmentProperty();
            }
        };
        
        private static final List> STYLEABLES;
        static  {
            final List> styleables = new ArrayList>(SkinBase.getClassCssMetaData());
            styleables.add(ARROW_POSITION);
            styleables.add(ARROW_DIRECTION);
            styleables.add(VALUE_ALIGNMENT);
            STYLEABLES = Collections.unmodifiableList(styleables);                
        }
    }
    
    /** 
     * @return The CssMetaData associated with this class, which may include the
     * CssMetaData of its super classes.
     */    
    public static List> getClassCssMetaData() {
        return StyleableProperties.STYLEABLES;
    }

    /**
     * This method should delegate to {@link Node#getClassCssMetaData()} so that
     * a Node's CssMetaData can be accessed without the need for reflection.
     * @return The CssMetaData associated with this node, which may include the
     * CssMetaData of its super classes.
     */
    public List> getCssMetaData() {
        return getClassCssMetaData();
    }
        
	// ==================================================================================================================
	// DRAW
	
	/**
	 * Construct the nodes. 
	 * Spinner uses a GridPane where the arrows and the node for the value are laid out according to the arrows direction and location.
	 * A place holder in inserted into the GridPane to hold the value node, so the spinner can alternate between editable or readonly mode, without having to recreate the GridPane.  
	 */
	private void createNodes()
	{
		// left arrow
		decrementArrow = new Region();
		decrementArrow.getStyleClass().add("idle");

		// place holder for showing the value
		valueHolderNode = new BorderPane();
		valueHolderNode.getStyleClass().add("valuePane");
		//valueHolderNode.setStyle("-fx-border-color: white;");
		
		// right arrow
		incrementArrow = new Region();
		incrementArrow.getStyleClass().add("idle");

		// construct a placeholder node
		skinNode = new BorderPane();
		skinNode.setCenter(valueHolderNode);

		// we're not catching the mouse events on the individual children, but let it bubble up to the parent and handle it there, this makes our life much more simple
		// process mouse clicks
		skinNode.setOnMouseClicked( (mouseEvent) -> {
			
			// if click was the in the greater vicinity of the decrement arrow
			if (mouseEventOverArrow(mouseEvent, decrementArrow))
			{
				// left
				NodeUtil.addStyleClass(decrementArrow, "clicked");
				NodeUtil.removeStyleClass(incrementArrow, "clicked");
				getSkinnable().decrement();
				unclickTimer.restart();
				return;
			}

			// if click was the in the greater vicinity of the increment arrow
			if (mouseEventOverArrow(mouseEvent, incrementArrow))
			{
				// right
				NodeUtil.removeStyleClass(decrementArrow, "clicked");
				NodeUtil.addStyleClass(incrementArrow, "clicked");
				getSkinnable().increment();
				unclickTimer.restart();
				return;
			}
		});
		// process mouse holds
		skinNode.setOnMousePressed( evt -> {
			
			// if click was the in the greater vicinity of the decrement arrow
			if (mouseEventOverArrow(evt, decrementArrow))
			{
				// left
				decrementArrow.getStyleClass().add("clicked");
				repeatDecrementClickTimer.restart();
			}

			// if click was the in the greater vicinity of the increment arrow
			else if (mouseEventOverArrow(evt, incrementArrow))
			{
				// right
				incrementArrow.getStyleClass().add("clicked");
				repeatIncrementClickTimer.restart();
				return;
			}
			
			// if a control does not have the focus, request focus
			ListSpinner lControl = getSkinnable();
			if (!lControl.isFocused() && lControl.isFocusTraversable()) {
				lControl.requestFocus();
			}
		});
		skinNode.setOnMouseReleased( evt -> {
			unclickArrows();
			repeatDecrementClickTimer.stop();
			repeatIncrementClickTimer.stop();
		});
		skinNode.setOnMouseExited( evt -> {
			unclickArrows();
			repeatDecrementClickTimer.stop();
			repeatIncrementClickTimer.stop();
		});
		// mouse wheel
		skinNode.setOnScroll( evt -> {
			// if click was the in the greater vicinity of the decrement arrow
			if (evt.getDeltaY() < 0 || evt.getDeltaX() < 0)
			{
				// left
				NodeUtil.addStyleClass(decrementArrow, "clicked");
				NodeUtil.removeStyleClass(incrementArrow, "clicked");
				getSkinnable().decrement();
				unclickTimer.restart();
				return;
			}

			// if click was the in the greater vicinity of the increment arrow
			if (evt.getDeltaY() > 0 || evt.getDeltaX() > 0)
			{
				// right
				NodeUtil.removeStyleClass(decrementArrow, "clicked");
				NodeUtil.addStyleClass(incrementArrow, "clicked");
				getSkinnable().increment();
				unclickTimer.restart();
				return;
			}
		});
		
		// key events
		getSkinnable().onKeyTypedProperty().set( keyEvent -> {
			KeyCode lKeyCode = keyEvent.getCode();
			if ( KeyCode.MINUS.equals(lKeyCode)
			  || KeyCode.SUBTRACT.equals(lKeyCode)
			  || KeyCode.DOWN.equals(lKeyCode)
			  || KeyCode.LEFT.equals(lKeyCode)
 			   ) {
				getSkinnable().decrement();
			}
			if ( KeyCode.PLUS.equals(lKeyCode)
			  || KeyCode.ADD.equals(lKeyCode)
			  || KeyCode.UP.equals(lKeyCode)
			  || KeyCode.RIGHT.equals(lKeyCode)
 			   ) {
				getSkinnable().increment();
			}
		});
		
		// add to self
		getSkinnable().getStyleClass().add(this.getClass().getSimpleName()); // always add self as style class, because CSS should relate to the skin not the control
		getChildren().add(skinNode);
	}
	private Region decrementArrow = null;
	private Region incrementArrow = null;
	private BorderPane skinNode = null;
	private BorderPane valueHolderNode;
	
	// timer to remove the click styling on the arrows after a certain delay
	final private Timer unclickTimer = new Timer( () -> {
		unclickArrows();
	}).withDelay(Duration.millis(100)).withRepeats(false);

	// timer to handle the holding of the decrement button
	final private Timer repeatDecrementClickTimer = new Timer( () -> {
		getSkinnable().decrement();
	}).withDelay(Duration.millis(500)).withCycleDuration(Duration.millis(50));
	
	// timer to handle the holding of the increment button
	final private Timer repeatIncrementClickTimer = new Timer( () -> {
		getSkinnable().increment();
	}).withDelay(Duration.millis(500)).withCycleDuration(Duration.millis(50));

	/**
	 * Check if the mouse event is considered to have happened over the arrow
	 * @param evt
	 * @param region
	 * @return
	 */
	private boolean mouseEventOverArrow(MouseEvent evt, Region region)
	{
		// if click was the in the greater vicinity of the arrow
		Point2D lClickInRelationToArrow = region.sceneToLocal(evt.getSceneX(), evt.getSceneY());
		if ( lClickInRelationToArrow.getX() >= 0.0 && lClickInRelationToArrow.getX() <= region.getWidth()
		  && lClickInRelationToArrow.getY() >= 0.0 && lClickInRelationToArrow.getY() <= region.getHeight()
		   )
		{
			return true;
		}
		return false;
	}
	
	/**
	 * Remove clicked CSS styling from the arrows
	 */
	private void unclickArrows()
	{
		decrementArrow.getStyleClass().remove("clicked");
		incrementArrow.getStyleClass().remove("clicked");
	}
	
	/**
	 * Put the correct node for the value's place holder: 
	 * - either the TextField when in editable mode, 
	 * - or a node generated by the cell factory when in readonly mode.  
	 */
	private void replaceValueNode()
	{
		// clear
		valueHolderNode.getChildren().clear();
		
		// if not editable
		if (getSkinnable().isEditable() == false)
		{
			// use the cell factory
			Node lNode = getSkinnable().getCellFactory().call(getSkinnable());
			//lNode.setStyle("-fx-border-color: blue;");
			valueHolderNode.setCenter(lNode);
			if (lNode.getStyleClass().contains("value") == false) lNode.getStyleClass().add("value");
			if (lNode.getStyleClass().contains("readonly") == false) lNode.getStyleClass().add("readonly");
		}
		else
		{
			// use the textfield
			if (textField == null) 
			{
				textField = new TextField();
				textField.getStyleClass().add("value");
				textField.getStyleClass().add("editable");
				
				// process text entry
				textField.focusedProperty().addListener(new InvalidationListener()
				{			
					@Override
					public void invalidated(Observable arg0)
					{
						if (textField.isFocused() == false) 
						{
							parse(textField);
						}
					}
				});
				textField.setOnAction( (actionEvent) -> {
					parse(textField);
				});
				textField.setOnKeyPressed( (keyEvent) -> { 
	                if (keyEvent.getCode() == KeyCode.ESCAPE) 
	                {
	    				// refresh
	    				refreshValue();
	                }
		        });
				
				// alignment
				textField.alignmentProperty().bind(valueAlignmentProperty());
			}
			valueHolderNode.setCenter(textField);
			//textField.setStyle("-fx-border-color: blue;");
		}
		
		// align
		alignValue();
	}
	private TextField textField = null;

	/**
	 * align the value inside the plave holder
	 */
	private void alignValue()
	{
		// valueHolderNode always only holds one child (the value)
		BorderPane.setAlignment(valueHolderNode.getChildren().get(0), valueAlignmentProperty().getValue());
	}
	
	// ==================================================================================================================
	// EDITABLE
	
	/**
	 * Parse the contents of the textfield
	 * @param textField
	 */
	protected void parse(TextField textField)
	{
		// get the text to parse
		String lText = textField.getText();

		// process it
		parse(lText);
		
		// refresh
		refreshValue();
		return;
	}
	
	/**
	 * Lays out the spinner, depending on the location and direction of the arrows.
	 */
	private void layout()
	{
		// get the things we decide on
		ArrowDirection lArrowDirection = getArrowDirection();
		ArrowPosition lArrowPosition = getArrowPosition();
		
		// get helper values
		ColumnConstraints lColumnValue = new ColumnConstraints(valueHolderNode.getMinWidth(), valueHolderNode.getPrefWidth(), Double.MAX_VALUE);
		lColumnValue.setHgrow(Priority.ALWAYS);
		
		// get helper values
		RowConstraints lRowValue = new RowConstraints(valueHolderNode.getMinHeight(), valueHolderNode.getPrefHeight(), Double.MAX_VALUE);
		lRowValue.setVgrow(Priority.ALWAYS);

		// create the grid
		skinNode.getChildren().clear();
		skinNode.setCenter(valueHolderNode);

		if (lArrowDirection == ArrowDirection.HORIZONTAL)
		{
			if (lArrowPosition == ArrowPosition.LEADING)
			{
				HBox lHBox = new HBox(0);
				lHBox.add(decrementArrow, new HBox.C().hgrow(Priority.ALWAYS));
				lHBox.add(incrementArrow, new HBox.C().hgrow(Priority.ALWAYS));
				skinNode.setLeft(lHBox);
				BorderPane.setAlignment(lHBox, Pos.CENTER_LEFT);
				//lHBox.setStyle("-fx-border-color: blue;");
			}
			if (lArrowPosition == ArrowPosition.TRAILING)
			{
				HBox lHBox = new HBox(0);
				lHBox.add(decrementArrow, new HBox.C().hgrow(Priority.ALWAYS));
				lHBox.add(incrementArrow, new HBox.C().hgrow(Priority.ALWAYS));
				skinNode.setRight(lHBox);
				BorderPane.setAlignment(lHBox, Pos.CENTER_RIGHT);
				//lHBox.setStyle("-fx-border-color: blue;");
			}
			if (lArrowPosition == ArrowPosition.SPLIT)
			{
				skinNode.setLeft(decrementArrow);
				skinNode.setRight(incrementArrow);
				BorderPane.setAlignment(decrementArrow, Pos.CENTER_LEFT);
				BorderPane.setAlignment(incrementArrow, Pos.CENTER_RIGHT);
			}
		}
		if (lArrowDirection == ArrowDirection.VERTICAL)
		{
			if (lArrowPosition == ArrowPosition.LEADING)
			{
				VBox lVBox = new VBox(0);
				lVBox.add(incrementArrow, new VBox.C().vgrow(Priority.ALWAYS));
				lVBox.add(decrementArrow, new VBox.C().vgrow(Priority.ALWAYS));
				skinNode.setLeft(lVBox);
				BorderPane.setAlignment(lVBox, Pos.CENTER_LEFT);
				//lVBox.setStyle("-fx-border-color: blue;");
			}
			if (lArrowPosition == ArrowPosition.TRAILING)
			{
				VBox lVBox = new VBox(0);
				lVBox.add(incrementArrow, new VBox.C().vgrow(Priority.ALWAYS));
				lVBox.add(decrementArrow, new VBox.C().vgrow(Priority.ALWAYS));
				skinNode.setRight(lVBox);
				BorderPane.setAlignment(lVBox, Pos.CENTER_RIGHT);
				//lVBox.setStyle("-fx-border-color: blue;");
			}
			if (lArrowPosition == ArrowPosition.SPLIT)
			{
				skinNode.setTop(incrementArrow);
				skinNode.setBottom(decrementArrow);
				BorderPane.setAlignment(incrementArrow, Pos.TOP_CENTER);
				BorderPane.setAlignment(decrementArrow, Pos.BOTTOM_CENTER);
			}
		}
	}
	
	/**
	 * Set the CSS according to the direction of the arrows, so the correct arrows are shown
	 */
	private void setArrowCSS()
	{
		decrementArrow.getStyleClass().remove("down-arrow");
		decrementArrow.getStyleClass().remove("left-arrow");
		incrementArrow.getStyleClass().remove("up-arrow");
		incrementArrow.getStyleClass().remove("right-arrow");
		if (getArrowDirection().equals(ArrowDirection.HORIZONTAL))
		{
			decrementArrow.getStyleClass().add("left-arrow");
			incrementArrow.getStyleClass().add("right-arrow");
		}
		else
		{
			decrementArrow.getStyleClass().add("down-arrow");
			incrementArrow.getStyleClass().add("up-arrow");
		}
	}
	
	// ==================================================================================================================
	// EDITABLE
	
	/**
	 * Parse the value (which usually comes from the TextField in the skin).
	 * If the value exists in the current items, select it.
	 * If not and a callback is defined, call the callback to have it handle it.
	 * Otherwise do nothing (leave it to the skin).
	 */
	public void parse(String text)
	{
		// strip
		String lText = text;
		String lPostfix = getSkinnable().getPostfix();
		if (lPostfix.length() > 0 && lText.endsWith(lPostfix)) {
			lText = lText.substring(0, lText.length() - lPostfix.length());
		}
		String lPrefix = getSkinnable().getPrefix();
		if (lPrefix.length() > 0 && lText.startsWith(lPrefix)) {
			lText = lText.substring(lPrefix.length());
		}
		
		// convert from string to value
		T lValue = getSkinnable().getStringConverter().fromString(lText);
		
		// if the value does exists in the domain
		int lItemIndex = getSkinnable().getItems().indexOf(lValue);
		if (lItemIndex >= 0)
		{
			// accept value and bail out
			getSkinnable().setValue(lValue);
			return;
		}
		
		// check to see if we have a addCallback
		Callback lAddCallback = getSkinnable().getAddCallback();
		if (lAddCallback != null)
		{
			// call the callback
			Integer lIndex = lAddCallback.call(lValue);
			
			// if the callback reports that it has processed the value by returning the index where it has added the item. (Or at least the index it wants to show now.)
			if (lIndex != null)
			{
				// accept value and bail out
				getSkinnable().setIndex(lIndex);
				return;
			}
		}
	}
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy