Преглед на файлове

:boom: New control MFXSpinner, as requested by #4

Controls Package
:bug: BoundTextField: remove text formatter binding as it was causing an exception, also it works anyway, so it was unnecessary
:bug: MFXIconWrapper: fix NullPointerException when creating an empty wrapper
:recycle: MFXIconWrapper: add handler to acquire focus

Utils Package
:bug: ListChangeProcessor: fix computeRemoval() method returning negative indexes
:bug: ListChangeProcessor: fix findShift() method by including the given index in the count too

Signed-off-by: palexdev <alessandro.parisi406@gmail.com>
palexdev преди 3 години
родител
ревизия
6d98cbd98c
променени са 23 файла, в които са добавени 2135 реда и са изтрити 71 реда
  1. 8 0
      CHANGELOG.md
  2. 47 18
      demo/src/test/java/Playground.java
  3. 0 2
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/BoundTextField.java
  4. 3 2
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXIconWrapper.java
  5. 355 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXSpinner.java
  6. 98 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/AbstractSpinnerModel.java
  7. 90 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/DoubleSpinnerModel.java
  8. 90 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/IntegerSpinnerModel.java
  9. 240 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/ListSpinnerModel.java
  10. 156 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/LocalDateSpinnerModel.java
  11. 106 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/NumberSpinnerModel.java
  12. 84 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/SpinnerModel.java
  13. 32 0
      materialfx/src/main/java/io/github/palexdev/materialfx/enums/ScaleBehavior.java
  14. 45 44
      materialfx/src/main/java/io/github/palexdev/materialfx/font/FontResources.java
  15. 397 0
      materialfx/src/main/java/io/github/palexdev/materialfx/layout/ScalableContentPane.java
  16. 307 0
      materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXSpinnerSkin.java
  17. 2 2
      materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXTextFieldSkin.java
  18. 3 3
      materialfx/src/main/java/io/github/palexdev/materialfx/utils/ListChangeProcessor.java
  19. 1 0
      materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnChanged.java
  20. 1 0
      materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnInvalidated.java
  21. 4 0
      materialfx/src/main/java/module-info.java
  22. 66 0
      materialfx/src/main/resources/io/github/palexdev/materialfx/css/MFXSpinner.css
  23. BIN
      materialfx/src/main/resources/io/github/palexdev/materialfx/fonts/MFXResources.ttf

+ 8 - 0
CHANGELOG.md

@@ -20,13 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - New utils class SwingFXUtils (copied from javafx.embed.swing)
 - Added fluent API builders for MaterialFX components and JavaFX Panes, as requested by #78
 - Added resource bundles and API for internationalization
+- Added new control, MFXSpinner
 
 ### Changed
 - ColorUtils: changed some method to be null-safe
 - MFXFilterPaneSkin: properly compute the minimum width
 - MFXTableViewSkin: allow to drag the filter dialog
+- MFXIconWrapper: added handler to acquire focus
 
 ### Fixed
+
 - MFXComboBoxSkin: ensure the caret position is at 0 if the combo box is not selectable
 - MFXTableViewSkin: ensure the dialog is on foreground
 - MFXTextField and all subclasses: fixed an issue with CSS and :focused PseudoClass. It was being ignored in some cases,
@@ -36,6 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - I18N: do not use URLClassLoader to load the ResourceBundles as using MaterialFX is other projects would lead to a
   MissingResourceException, instead change the bundle base name returned by getBundleBaseName() with the complete path
   to the bundles
+- BoundTextField: remove text formatter binding as it was causing an exception, also it works anyway, so it was
+  unnecessary
+- MFXIconWrapper: fix NullPointerException when creating an empty wrapper
+- ListChangeProcessor: fix computeRemoval() method returning negative indexes
+- ListChangeProcessor: fix findShift() method by including the given index in the count too
 
 ## [11.13.0] - 22-01-2022
 _This version won't follow the above scheme as the amount of changes and commits is simply too huge and there would be

+ 47 - 18
demo/src/test/java/Playground.java

@@ -1,17 +1,20 @@
 import fr.brouillard.oss.cssfx.CSSFX;
+import io.github.palexdev.materialfx.MFXResourcesLoader;
 import io.github.palexdev.materialfx.controls.MFXButton;
-import io.github.palexdev.materialfx.controls.MFXPasswordField;
-import io.github.palexdev.materialfx.controls.MFXRectangleToggleNode;
-import io.github.palexdev.materialfx.factories.InsetsFactory;
-import io.github.palexdev.materialfx.font.MFXFontIcon;
+import io.github.palexdev.materialfx.controls.MFXSpinner;
+import io.github.palexdev.materialfx.controls.models.spinner.ListSpinnerModel;
 import javafx.application.Application;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
 import javafx.geometry.Pos;
 import javafx.scene.Scene;
 import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.Region;
+import javafx.scene.layout.HBox;
 import javafx.stage.Stage;
 import org.scenicview.ScenicView;
 
+import java.util.List;
+
 public class Playground extends Application {
 
 	@Override
@@ -19,24 +22,50 @@ public class Playground extends Application {
 		CSSFX.start();
 		BorderPane borderPane = new BorderPane();
 
-		MFXPasswordField textField = new MFXPasswordField("", "Prompt", "Floating Text");
+		ObservableList<String> strings = FXCollections.observableArrayList(
+				"String 1",
+				"String 2",
+				"String 3",
+				"String 4",
+				"String 5",
+				"String 6",
+				"String 7",
+				"String 8"
+		);
 
-		MFXRectangleToggleNode toggleNode = new MFXRectangleToggleNode("This should be a long text");
-		toggleNode.setLabelLeadingIcon(new MFXFontIcon("mfx-google", 48));
-		toggleNode.setLabelTrailingIcon(new MFXFontIcon("mfx-google", 24));
+		MFXSpinner<String> spinner = new MFXSpinner<>();
+		spinner.getStylesheets().add(MFXResourcesLoader.load("css/MFXSpinner.css"));
+		spinner.setSpinnerModel(new ListSpinnerModel<>());
+		spinner.getSpinnerModel().setWrapAround(true);
+		((ListSpinnerModel<String>) spinner.getSpinnerModel()).setItems(strings);
+		spinner.setTextTransformer((focused, text) -> ((!focused || !spinner.isEditable()) && !text.isEmpty()) ? text + " cm" : text);
 
-		MFXButton button = new MFXButton("Click Me!");
-		button.setOnAction(event -> textField.setShowPassword(!textField.isShowPassword()));
-		button.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
-		BorderPane.setAlignment(button, Pos.TOP_CENTER);
-		BorderPane.setMargin(button, InsetsFactory.top(10));
+		MFXButton add = new MFXButton("Add");
+		add.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().addAll(2, List.of("String Added 1", "String Added 2")));
+		MFXButton remove = new MFXButton("Remove");
+		remove.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().clear());
+		MFXButton removeSel = new MFXButton("Remove Selected");
+		removeSel.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().remove(((ListSpinnerModel<String>) spinner.getSpinnerModel()).getCurrentIndex()));
+		MFXButton replace = new MFXButton("Replace");
+		replace.setOnAction(event -> ((ListSpinnerModel<String>) spinner.getSpinnerModel()).getItems().set(((ListSpinnerModel<String>) spinner.getSpinnerModel()).getCurrentIndex(), "Replaced"));
+		MFXButton change = new MFXButton("Change List");
+		change.setOnAction(event -> {
+			ListSpinnerModel<String> model = (ListSpinnerModel<String>) spinner.getSpinnerModel();
+			model.setItems(FXCollections.observableArrayList(
+					"String 9",
+					"String 10",
+					"String 11",
+					"String 12",
+					"String 1234567890"
+			));
+		});
+		HBox box = new HBox(15, add, remove, removeSel, replace, change);
+		box.setAlignment(Pos.CENTER);
 
-		borderPane.getStylesheets().add(Playground.class.getResource("CustomField.css").toString());
-		borderPane.setTop(button);
-		borderPane.setCenter(toggleNode);
+		borderPane.setCenter(spinner);
+		borderPane.setBottom(box);
 		Scene scene = new Scene(borderPane, 800, 600);
 		primaryStage.setScene(scene);
-		primaryStage.setOnShown(event -> button.requestFocus());
 		primaryStage.show();
 		ScenicView.show(scene);
 	}

+ 0 - 2
materialfx/src/main/java/io/github/palexdev/materialfx/controls/BoundTextField.java

@@ -63,7 +63,6 @@ public class BoundTextField extends TextField {
 		setEditable(textField.isEditable());
 		setAlignment(textField.getAlignment());
 		setPrefColumnCount(textField.getPrefColumnCount());
-		setTextFormatter(textField.getTextFormatter());
 		selectRange(textField.getSelection().getStart(), textField.getSelection().getEnd());
 		positionCaret(textField.getCaretPosition());
 
@@ -74,7 +73,6 @@ public class BoundTextField extends TextField {
 		editableProperty().bind(textField.editableProperty());
 		alignmentProperty().bind(textField.alignmentProperty());
 		prefColumnCountProperty().bind(textField.prefColumnCountProperty());
-		textFormatterProperty().bind(textField.textFormatterProperty());
 	}
 
 	@Override

+ 3 - 2
materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXIconWrapper.java

@@ -140,6 +140,7 @@ public class MFXIconWrapper extends StackPane {
 		setMinSize(USE_PREF_SIZE, USE_PREF_SIZE);
 		setMaxSize(USE_PREF_SIZE, USE_PREF_SIZE);
 
+		addEventHandler(MouseEvent.MOUSE_PRESSED, event -> requestFocus());
 		icon.addListener((observable, oldValue, newValue) -> {
 			super.getChildren().remove(oldValue);
 			manageIcon(newValue);
@@ -187,8 +188,8 @@ public class MFXIconWrapper extends StackPane {
 	protected void layoutChildren() {
 		super.layoutChildren();
 
-		if (getSize() == -1) {
-			Node icon = getIcon();
+		Node icon = getIcon();
+		if (icon != null && getSize() == -1) {
 			double iW = icon.prefWidth(-1);
 			double iH = icon.prefHeight(-1);
 			Insets padding = getPadding();

+ 355 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXSpinner.java

@@ -0,0 +1,355 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls;
+
+import io.github.palexdev.materialfx.MFXResourcesLoader;
+import io.github.palexdev.materialfx.beans.properties.functional.BiFunctionProperty;
+import io.github.palexdev.materialfx.beans.properties.functional.ConsumerProperty;
+import io.github.palexdev.materialfx.beans.properties.functional.SupplierProperty;
+import io.github.palexdev.materialfx.controls.models.spinner.SpinnerModel;
+import io.github.palexdev.materialfx.skins.MFXSpinnerSkin;
+import io.github.palexdev.materialfx.utils.NodeUtils;
+import javafx.beans.property.*;
+import javafx.geometry.Orientation;
+import javafx.scene.Node;
+import javafx.scene.control.Control;
+import javafx.scene.control.Skin;
+import javafx.scene.input.MouseEvent;
+
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * MaterialFX implementation of {@link javafx.scene.control.Spinner} with a modern UI.
+ * <p>
+ * The spinner can work on any object you want but you will have to implement your own
+ * {@link SpinnerModel}. MaterialFX just like JavaFX offers 4 default models for: doubles,
+ * integers, local dates and lists.
+ * <p>
+ * <b>Without a {@link SpinnerModel} the spinner is useless!</b>
+ * <p></p>
+ * {@code MFXSpinner} offers the following features:
+ * <p> - You can set a prompt text for the field, {@link #promptTextProperty()}
+ * <p> - You can set the spinner to be editable or not, {@link #editableProperty()}
+ * <p> - You can allow/disallow the selection of the text, {@link #selectableProperty()}
+ * <p> - You can specify the action to run when the spinner is editable and ENTER is pressed,
+ * {@link #onCommitProperty()}
+ * <p> You can specify a function to transform the spinner's text. This can be useful for example when
+ * you want to add unit of measures to the text. The function carries the focus state of the editor
+ * (this way you can remove/add text according to the focus state) and the T value converted to a String
+ * <p> - You can easily change the orientation of the spinner, {@link #orientationProperty()}
+ * <p> - You can easily change the icons, {@link #prevIconSupplierProperty()}, {@link #nextIconSupplierProperty()}
+ * <p> - You can specify the gap between the text and the icon, {@link #graphicTextGapProperty()}
+ */
+public class MFXSpinner<T> extends Control {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final String STYLE_CLASS = "mfx-spinner";
+	private final String STYLESHEET = MFXResourcesLoader.load("css/MFXSpinner.css");
+
+	private final ReadOnlyObjectWrapper<T> value = new ReadOnlyObjectWrapper<>();
+	private final ObjectProperty<SpinnerModel<T>> spinnerModel = new SimpleObjectProperty<>();
+	private final StringProperty promptText = new SimpleStringProperty("");
+	private final BooleanProperty editable = new SimpleBooleanProperty(false);
+	private final BooleanProperty selectable = new SimpleBooleanProperty(true);
+	private final ConsumerProperty<String> onCommit = new ConsumerProperty<>();
+	private final BiFunctionProperty<Boolean, String, String> textTransformer = new BiFunctionProperty<>();
+
+	private final ObjectProperty<Orientation> orientation = new SimpleObjectProperty<>(Orientation.HORIZONTAL);
+	private final SupplierProperty<Node> prevIconSupplier = new SupplierProperty<>();
+	private final SupplierProperty<Node> nextIconSupplier = new SupplierProperty<>();
+	private final DoubleProperty graphicTextGap = new SimpleDoubleProperty(10.0);
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public MFXSpinner() {
+		this(null);
+	}
+
+	public MFXSpinner(SpinnerModel<T> spinnerModel) {
+		initialize();
+		setSpinnerModel(spinnerModel);
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+	private void initialize() {
+		getStyleClass().add(STYLE_CLASS);
+		spinnerModelProperty().addListener((observable, oldValue, newValue) -> {
+			value.unbind();
+			if (newValue != null) {
+				value.bind(newValue.valueProperty());
+			}
+		});
+
+		defaultIcons();
+	}
+
+	/**
+	 * Restores the defaults for {@link #prevIconSupplierProperty()} and {@link #nextIconSupplierProperty()}.
+	 */
+	public void defaultIcons() {
+		setPrevIconSupplier(() -> {
+			MFXIconWrapper icon = new MFXIconWrapper("mfx-minus", 16, -1).defaultRippleGeneratorBehavior();
+			icon.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+				SpinnerModel<T> model = getSpinnerModel();
+				if (model != null) model.previous();
+			});
+			NodeUtils.makeRegionCircular(icon);
+			return icon;
+		});
+		setNextIconSupplier(() -> {
+			MFXIconWrapper icon = new MFXIconWrapper("mfx-plus", 16, -1).defaultRippleGeneratorBehavior();
+			icon.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+				SpinnerModel<T> model = getSpinnerModel();
+				if (model != null) model.next();
+			});
+			NodeUtils.makeRegionCircular(icon);
+			return icon;
+		});
+	}
+
+	/**
+	 * If the spinner is editable, {@link #editableProperty()}, pressing the ENTER key will
+	 * trigger the action specified by {@link #onCommitProperty()}.
+	 */
+	public void commit(String text) {
+		Consumer<String> onCommit = getOnCommit();
+		if (onCommit != null) onCommit.accept(text);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected Skin<?> createDefaultSkin() {
+		return new MFXSpinnerSkin<>(this);
+	}
+
+	@Override
+	public String getUserAgentStylesheet() {
+		return STYLESHEET;
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+
+	public T getValue() {
+		return value.get();
+	}
+
+	/**
+	 * Specifies the current selected value for the spinner.
+	 * <p>
+	 * Note that this property is read-only, you can set the value with {@link #setValue(Object)}
+	 * but it will fail with an exception if the {@link #spinnerModelProperty()} is null.
+	 */
+	public ReadOnlyObjectProperty<T> valueProperty() {
+		return value.getReadOnlyProperty();
+	}
+
+	public void setValue(T value) {
+		getSpinnerModel().setValue(value);
+	}
+
+	public SpinnerModel<T> getSpinnerModel() {
+		return spinnerModel.get();
+	}
+
+	/**
+	 * Specifies the spinner's model, responsible for handling the spinner's value
+	 * according to the data type.
+	 */
+	public ObjectProperty<SpinnerModel<T>> spinnerModelProperty() {
+		return spinnerModel;
+	}
+
+	public void setSpinnerModel(SpinnerModel<T> spinnerModel) {
+		this.spinnerModel.set(spinnerModel);
+	}
+
+	public String getPromptText() {
+		return promptText.get();
+	}
+
+	/**
+	 * Specifies the prompt text for the spinner's text field.
+	 */
+	public StringProperty promptTextProperty() {
+		return promptText;
+	}
+
+	public void setPromptText(String promptText) {
+		this.promptText.set(promptText);
+	}
+
+	public boolean isEditable() {
+		return editable.get();
+	}
+
+	/**
+	 * Specifies whether the spinner's text field is editable.
+	 * <p>
+	 * If you edit the text you must confirm the change by pressing the ENTER
+	 * key, this will trigger the {@link #commit(String)} method, more info here {@link #onCommitProperty()}.
+	 */
+	public BooleanProperty editableProperty() {
+		return editable;
+	}
+
+	public void setEditable(boolean editable) {
+		this.editable.set(editable);
+	}
+
+	public boolean isSelectable() {
+		return selectable.get();
+	}
+
+	/**
+	 * Specifies whether the spinner's text is selectable.
+	 */
+	public BooleanProperty selectableProperty() {
+		return selectable;
+	}
+
+	public void setSelectable(boolean selectable) {
+		this.selectable.set(selectable);
+	}
+
+	public Consumer<String> getOnCommit() {
+		return onCommit.get();
+	}
+
+	/**
+	 * Specifies the action to perform when editing the spinner's text
+	 * and confirming the changes by pressing ENTER.
+	 * <p>
+	 * The action is a {@link Consumer} which carries the modified text.
+	 * To change the spinner's value with that for example you probably want to validate
+	 * the text, parse a valid T object and then set the value.
+	 */
+	public ConsumerProperty<String> onCommitProperty() {
+		return onCommit;
+	}
+
+	public void setOnCommit(Consumer<String> onCommit) {
+		this.onCommit.set(onCommit);
+	}
+
+	public BiFunction<Boolean, String, String> getTextTransformer() {
+		return textTransformer.get();
+	}
+
+	/**
+	 * The text transformer is a {@link BiFunction} that allows you to change the
+	 * spinner's text when the spinner's text field acquires/loses focus.
+	 * <p>
+	 * This can be useful for example when you want to add the unit of measure to the
+	 * spinner's text. Usually in such controls the unit of measure is added when the control
+	 * is not focused and removed when editing the text.
+	 * <p></p>
+	 * An example could be:
+	 * <pre>
+	 * {@code
+	 *      MFXSpinner spinner = ...;
+	 *      spinner.setTextTransformer((focused, text) -> (!focused || !spinner.isEditable()) ? text + " meters" : text);
+	 * }
+	 * </pre>
+	 */
+	public BiFunctionProperty<Boolean, String, String> textTransformerProperty() {
+		return textTransformer;
+	}
+
+	public void setTextTransformer(BiFunction<Boolean, String, String> textTransformer) {
+		this.textTransformer.set(textTransformer);
+	}
+
+	public Orientation getOrientation() {
+		return orientation.get();
+	}
+
+	/**
+	 * Specifies the spinner's orientation.
+	 */
+	public ObjectProperty<Orientation> orientationProperty() {
+		return orientation;
+	}
+
+	public void setOrientation(Orientation orientation) {
+		this.orientation.set(orientation);
+	}
+
+	public Supplier<Node> getPrevIconSupplier() {
+		return prevIconSupplier.get();
+	}
+
+	/**
+	 * The {@link Supplier} used to build the icons which should
+	 * trigger {@link SpinnerModel#previous()}.
+	 * <p>
+	 * Note that the {@link #defaultIcons()} add the needed event handlers
+	 * to use the {@link SpinnerModel}, it is not handled automatically!
+	 */
+	public SupplierProperty<Node> prevIconSupplierProperty() {
+		return prevIconSupplier;
+	}
+
+	public void setPrevIconSupplier(Supplier<Node> prevIconSupplier) {
+		this.prevIconSupplier.set(prevIconSupplier);
+	}
+
+	public Supplier<Node> getNextIconSupplier() {
+		return nextIconSupplier.get();
+	}
+
+	/**
+	 * The {@link Supplier} used to build the icons which should
+	 * trigger {@link SpinnerModel#next()}.
+	 * <p>
+	 * Note that the {@link #defaultIcons()} add the needed event handlers
+	 * to use the {@link SpinnerModel}, it is not handled automatically!
+	 */
+	public SupplierProperty<Node> nextIconSupplierProperty() {
+		return nextIconSupplier;
+	}
+
+	public void setNextIconSupplier(Supplier<Node> nextIconSupplier) {
+		this.nextIconSupplier.set(nextIconSupplier);
+	}
+
+	public double getGraphicTextGap() {
+		return graphicTextGap.get();
+	}
+
+	/**
+	 * Specifies the space between the spinner's text and the two icons.
+	 */
+	public DoubleProperty graphicTextGapProperty() {
+		return graphicTextGap;
+	}
+
+	public void setGraphicTextGap(double graphicTextGap) {
+		this.graphicTextGap.set(graphicTextGap);
+	}
+}

+ 98 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/AbstractSpinnerModel.java

@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+
+/**
+ * Base implementation for {@link SpinnerModel}.
+ * <p>
+ * This is still not enough to use with a spinner but it's a good base to implement
+ * new models.
+ * Defines all the specification from the {@link SpinnerModel} interface and adds a
+ * new property, {@link #defaultValueProperty()}, which is the spinner's value when calling {@link #reset()}.
+ */
+public abstract class AbstractSpinnerModel<T> implements SpinnerModel<T> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	protected final ObjectProperty<T> value = new SimpleObjectProperty<>();
+	protected final ObjectProperty<T> defaultValue = new SimpleObjectProperty<>();
+	protected boolean wrapAround;
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * Sets the spinner's value to the value specified by {@link #defaultValueProperty()}.
+	 */
+	@Override
+	public void reset() {
+		setValue(getDefaultValue());
+	}
+
+	@Override
+	public T getValue() {
+		return value.get();
+	}
+
+	@Override
+	public ObjectProperty<T> valueProperty() {
+		return value;
+	}
+
+	@Override
+	public void setValue(T value) {
+		this.value.set(value);
+	}
+
+	@Override
+	public boolean isWrapAround() {
+		return wrapAround;
+	}
+
+	@Override
+	public void setWrapAround(boolean wrapAround) {
+		this.wrapAround = wrapAround;
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public T getDefaultValue() {
+		return defaultValue.get();
+	}
+
+	/**
+	 * Specifies the default value of the spinner.
+	 * <p></p>
+	 * Note that this may not be the spinner's initial value, and usually this
+	 * is used when calling the {@link #reset()} method (depends on the implementation).
+	 */
+	public ObjectProperty<T> defaultValueProperty() {
+		return defaultValue;
+	}
+
+	public void setDefaultValue(T defaultValue) {
+		this.defaultValue.set(defaultValue);
+	}
+}
+

+ 90 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/DoubleSpinnerModel.java

@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.others.FunctionalStringConverter;
+
+import java.util.Objects;
+
+/**
+ * Concrete implementation of {@link NumberSpinnerModel} to work with double value.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@link Double#parseDouble(String)} and {@link Objects#toString(Object)}
+ * <p> - The default value is 0.0
+ * <p> - The min value is 0.0
+ * <p> - The max value is {@link Double#MAX_VALUE}
+ * <p> - The increment is 1.0
+ * <p> - The initial value depends on the chosen constructor
+ */
+public class DoubleSpinnerModel extends NumberSpinnerModel<Double> {
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public DoubleSpinnerModel() {
+		this(0.0);
+	}
+
+	public DoubleSpinnerModel(double initialValue) {
+		setConverter(FunctionalStringConverter.converter(
+				Double::parseDouble,
+				Objects::toString
+		));
+		setDefaultValue(0.0);
+		setMin(0.0);
+		setMax(Double.MAX_VALUE);
+		setIncrement(1.0);
+		setValue(initialValue);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * Increments the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is greater than the {@link #maxProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #minProperty()}.
+	 */
+	@Override
+	public void next() {
+		double newVal = getValue() + getIncrement();
+		if (newVal > getMax()) {
+			newVal = isWrapAround() ? getMin() : getMax();
+		}
+		setValue(newVal);
+	}
+
+	/**
+	 * Decrements the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is lesser than the {@link #minProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #maxProperty()}.
+	 */
+	@Override
+	public void previous() {
+		double newVal = getValue() - getIncrement();
+		if (newVal < getMin()) {
+			newVal = isWrapAround() ? getMax() : getMin();
+		}
+		setValue(newVal);
+	}
+}

+ 90 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/IntegerSpinnerModel.java

@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.others.FunctionalStringConverter;
+
+import java.util.Objects;
+
+/**
+ * Concrete implementation of {@link NumberSpinnerModel} to work with integer value.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@link Integer#parseInt(String)} and {@link Objects#toString(Object)}
+ * <p> - The default value is 0
+ * <p> - The min value is 0
+ * <p> - The max value is {@link Integer#MAX_VALUE}
+ * <p> - The increment is 1
+ * <p> - The initial value depends on the chosen constructor
+ */
+public class IntegerSpinnerModel extends NumberSpinnerModel<Integer> {
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public IntegerSpinnerModel() {
+		this(0);
+	}
+
+	public IntegerSpinnerModel(int initialValue) {
+		setConverter(FunctionalStringConverter.converter(
+				Integer::parseInt,
+				Objects::toString
+		));
+		setDefaultValue(0);
+		setMin(0);
+		setMax(Integer.MAX_VALUE);
+		setIncrement(1);
+		setValue(initialValue);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * Increments the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is greater than the {@link #maxProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #minProperty()}.
+	 */
+	@Override
+	public void next() {
+		int newVal = getValue() + getIncrement();
+		if (newVal > getMax()) {
+			newVal = isWrapAround() ? getMin() : getMax();
+		}
+		setValue(newVal);
+	}
+
+	/**
+	 * Decrements the current value by {@link #incrementProperty()}.
+	 * <p></p>
+	 * If the new value is lesser than the {@link #minProperty()} and {@link #isWrapAround()} is true
+	 * the new value will be the {@link #maxProperty()}.
+	 */
+	@Override
+	public void previous() {
+		int newVal = getValue() - getIncrement();
+		if (newVal < getMin()) {
+			newVal = isWrapAround() ? getMax() : getMin();
+		}
+		setValue(newVal);
+	}
+}

+ 240 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/ListSpinnerModel.java

@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.ListChangeProcessor;
+import io.github.palexdev.materialfx.utils.others.FunctionalStringConverter;
+import io.github.palexdev.virtualizedfx.beans.NumberRange;
+import io.github.palexdev.virtualizedfx.utils.ListChangeHelper;
+import javafx.beans.property.*;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.util.StringConverter;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Concrete implementation of {@link AbstractSpinnerModel} to work with lists of any type.
+ * <p></p>
+ * {@code ListSpinnerModel} adds the {@link #converterProperty()} (since we know the kind of data the model will deal with),
+ * and three new properties, {@link #itemsProperty()}, {@link #getCurrentIndex()}, {@link #incrementProperty()}.
+ * <p></p>
+ * The model works by keeping the current value's index in the list and updating that index, even
+ * when the list is modified, see {@link #updateCurrentIndex(ListChangeListener.Change)}.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@code toString(T)} on the value or empty string if the value is null
+ * <p> - The default value is an empty {@link ObservableList}
+ * <p> - The increment is 1
+ * <p> - The initial value is the first element of the list (if not empty)
+ */
+public class ListSpinnerModel<T> extends AbstractSpinnerModel<T> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ListProperty<T> items = new SimpleListProperty<>();
+	private int currentIndex = -1;
+	private final ObjectProperty<StringConverter<T>> converter = new SimpleObjectProperty<>();
+	private final IntegerProperty increment = new SimpleIntegerProperty();
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public ListSpinnerModel() {
+		this(FXCollections.observableArrayList());
+	}
+
+	public ListSpinnerModel(ObservableList<T> items) {
+		setConverter(FunctionalStringConverter.to(t -> t != null ? t.toString() : ""));
+		setItems(items);
+		setIncrement(1);
+
+		this.items.addListener((observable, oldValue, newValue) -> {
+			if (oldValue != newValue) reset();
+		});
+		this.items.addListener((ListChangeListener<? super T>) this::updateCurrentIndex);
+
+		if (!items.isEmpty()) {
+			currentIndex = 0;
+			setValue(items.get(currentIndex));
+		}
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+
+	/**
+	 * Responsible for updating the current value's index when the list changes.
+	 * <p></p>
+	 * The value won't change as the index will always be updated according to the value,
+	 * unless the current value is removed from the list.
+	 */
+	private void updateCurrentIndex(ListChangeListener.Change<? extends T> change) {
+		if (currentIndex == -1 && !change.getList().isEmpty()) {
+			currentIndex = 0;
+			setValue(change.getList().get(currentIndex));
+			return;
+		}
+
+		if (change.getList().isEmpty()) {
+			reset();
+			return;
+		}
+
+		ListChangeHelper.Change c = ListChangeHelper.processChange(change, NumberRange.of(0, Integer.MAX_VALUE));
+		ListChangeProcessor updater = new ListChangeProcessor(Set.of(currentIndex));
+		c.processReplacement((replaced, removed) -> {
+			T value = items.get(currentIndex);
+			setValue(value);
+		});
+		c.processAddition((from, to, added) -> {
+			updater.computeAddition(added.size(), from);
+			List<Integer> indexes = new ArrayList<>(updater.getIndexes());
+			if (!indexes.isEmpty()) {
+				currentIndex = indexes.get(0);
+				T value = items.get(currentIndex);
+				setValue(value);
+			}
+		});
+		c.processRemoval((from, to, removed) -> {
+			updater.computeRemoval(removed, from);
+			List<Integer> indexes = new ArrayList<>(updater.getIndexes());
+			if (!indexes.isEmpty()) {
+				currentIndex = indexes.get(0);
+				T value = items.get(currentIndex);
+				setValue(value);
+			}
+		});
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+
+	/**
+	 * If the items list is empty exits immediately.
+	 * <p></p>
+	 * Increments the current index by {@link #incrementProperty()}, if the new index
+	 * is greater than the list's last index and {@link #isWrapAround()} is true, the new index
+	 * will be set to 0, otherwise to {@code items.size() - 1}
+	 * <p></p>
+	 * At the end the value is updated with the new index.
+	 */
+	@Override
+	public void next() {
+		if (items.isEmpty()) return;
+		int newIndex = currentIndex + getIncrement();
+		if (newIndex > items.size() - 1) {
+			newIndex = isWrapAround() ? 0 : items.size() - 1;
+		}
+		currentIndex = newIndex;
+		setValue(items.get(newIndex));
+	}
+
+	/**
+	 * If the items list is empty exits immediately.
+	 * <p></p>
+	 * Decrements the current index by {@link #incrementProperty()}, if the new index
+	 * is lesser than 0 and {@link #isWrapAround()} is true, the new index
+	 * will be set to {@code items.size() - 1}, otherwise to 0
+	 * <p></p>
+	 * At the end the value is updated with the new index.
+	 */
+	@Override
+	public void previous() {
+		if (items.isEmpty()) return;
+		int newIndex = currentIndex - getIncrement();
+		if (newIndex < 0) {
+			newIndex = isWrapAround() ? items.size() - 1 : 0;
+		}
+		currentIndex = newIndex;
+		setValue(items.get(newIndex));
+	}
+
+	/**
+	 * Resets the spinner's value to the value specified by {@link #defaultValueProperty()},
+	 * and the current index to -1;
+	 */
+	@Override
+	public void reset() {
+		super.reset();
+		currentIndex = -1;
+	}
+
+	@Override
+	public StringConverter<T> getConverter() {
+		return converter.get();
+	}
+
+	@Override
+	public ObjectProperty<StringConverter<T>> converterProperty() {
+		return converter;
+	}
+
+	@Override
+	public void setConverter(StringConverter<T> converter) {
+		this.converter.set(converter);
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+
+	/**
+	 * @return the current value's index in the items list
+	 */
+	public int getCurrentIndex() {
+		return currentIndex;
+	}
+
+	public ObservableList<T> getItems() {
+		return items.get();
+	}
+
+	/**
+	 * Specifies the items list.
+	 */
+	public ListProperty<T> itemsProperty() {
+		return items;
+	}
+
+	public void setItems(ObservableList<T> items) {
+		this.items.set(items);
+	}
+
+	public int getIncrement() {
+		return increment.get();
+	}
+
+	/**
+	 * Specifies the increment/decrement value to add/subtract from
+	 * the current index when calling {@link #next()} or {@link #previous()}.
+	 */
+	public IntegerProperty incrementProperty() {
+		return increment;
+	}
+
+	public void setIncrement(int increment) {
+		this.increment.set(increment);
+	}
+}

+ 156 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/LocalDateSpinnerModel.java

@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.utils.others.dates.DateStringConverter;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.util.StringConverter;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.format.FormatStyle;
+import java.time.temporal.TemporalAmount;
+
+/**
+ * Concrete implementation of {@link AbstractSpinnerModel} to work with {@link LocalDate} values.
+ * <p></p>
+ * {@code LocalDateSpinnerModel} adds the {@link #converterProperty()} (since we know the kind of data the model will deal with),
+ * and three new properties, {@link #minProperty()}, {@link #maxProperty()} and {@link #incrementProperty()}.
+ * <p></p>
+ * The constructor initializes the model with these values:
+ * <p> - The converter uses {@link DateStringConverter} with format {@link FormatStyle#MEDIUM}
+ * <p> - The default value is {@link LocalDate#EPOCH}
+ * <p> - The min value is {@link LocalDate#EPOCH}
+ * <p> - The max value is {@link LocalDate#MAX}
+ * <p> - The increment is {@link Duration#ofDays(long)}, 1 day
+ * <p> - The initial value depends on the chosen constructor
+ */
+public class LocalDateSpinnerModel extends AbstractSpinnerModel<LocalDate> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ObjectProperty<StringConverter<LocalDate>> converter = new SimpleObjectProperty<>();
+	private final ObjectProperty<LocalDate> min = new SimpleObjectProperty<>();
+	private final ObjectProperty<LocalDate> max = new SimpleObjectProperty<>();
+	private final ObjectProperty<TemporalAmount> increment = new SimpleObjectProperty<>();
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public LocalDateSpinnerModel() {
+		this(LocalDate.EPOCH);
+	}
+
+	public LocalDateSpinnerModel(LocalDate initialValue) {
+		setConverter(new DateStringConverter(FormatStyle.MEDIUM));
+		setDefaultValue(LocalDate.EPOCH);
+		setMin(LocalDate.EPOCH);
+		setMax(LocalDate.MAX);
+		setIncrement(Duration.ofDays(1));
+		setValue(initialValue);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	public void next() {
+		LocalDate next = getValue().plus(getIncrement());
+		if (next.isAfter(getMax())) {
+			next = isWrapAround() ? getMin() : getMax();
+		}
+		setValue(next);
+	}
+
+	@Override
+	public void previous() {
+		LocalDate prev = getValue().minus(getIncrement());
+		if (prev.isBefore(getMin())) {
+			prev = isWrapAround() ? getMax() : getMin();
+		}
+		setValue(prev);
+	}
+
+	@Override
+	public StringConverter<LocalDate> getConverter() {
+		return converter.get();
+	}
+
+	@Override
+	public ObjectProperty<StringConverter<LocalDate>> converterProperty() {
+		return converter;
+	}
+
+	@Override
+	public void setConverter(StringConverter<LocalDate> converter) {
+		this.converter.set(converter);
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public LocalDate getMin() {
+		return min.get();
+	}
+
+	/**
+	 * Specifies the minimum date reachable by the spinner.
+	 */
+	public ObjectProperty<LocalDate> minProperty() {
+		return min;
+	}
+
+	public void setMin(LocalDate min) {
+		this.min.set(min);
+	}
+
+	public LocalDate getMax() {
+		return max.get();
+	}
+
+	/**
+	 * Specifies the maximum date reachable by the spinner.
+	 */
+	public ObjectProperty<LocalDate> maxProperty() {
+		return max;
+	}
+
+	public void setMax(LocalDate max) {
+		this.max.set(max);
+	}
+
+	public TemporalAmount getIncrement() {
+		return increment.get();
+	}
+
+	/**
+	 * Specifies the increment/decrement value to add/subtract from
+	 * the current index when calling {@link #next()} or {@link #previous()}.
+	 * <p></p>
+	 * The amount is a generic {@link TemporalAmount}.
+	 */
+	public ObjectProperty<TemporalAmount> incrementProperty() {
+		return increment;
+	}
+
+	public void setIncrement(TemporalAmount increment) {
+		this.increment.set(increment);
+	}
+}

+ 106 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/NumberSpinnerModel.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.util.StringConverter;
+
+/**
+ * Base class to easily implement spinner models for numeric values, extends {@link AbstractSpinnerModel}.
+ * <p></p>
+ * {@code NumberSpinnerModel} adds the {@link #converterProperty()} (since we know the kind of data the model will deal with),
+ * and three new properties, {@link #minProperty()}, {@link #maxProperty()}, {@link #incrementProperty()}.
+ */
+public abstract class NumberSpinnerModel<T extends Number> extends AbstractSpinnerModel<T> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ObjectProperty<StringConverter<T>> converter = new SimpleObjectProperty<>();
+	private final ObjectProperty<T> min = new SimpleObjectProperty<>();
+	private final ObjectProperty<T> max = new SimpleObjectProperty<>();
+	private final ObjectProperty<T> increment = new SimpleObjectProperty<>();
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	public StringConverter<T> getConverter() {
+		return converter.get();
+	}
+
+	@Override
+	public ObjectProperty<StringConverter<T>> converterProperty() {
+		return converter;
+	}
+
+	@Override
+	public void setConverter(StringConverter<T> converter) {
+		this.converter.set(converter);
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public T getMin() {
+		return min.get();
+	}
+
+	/**
+	 * Specifies the minimum number reachable by the spinner.
+	 */
+	public ObjectProperty<T> minProperty() {
+		return min;
+	}
+
+	public void setMin(T min) {
+		this.min.set(min);
+	}
+
+	public T getMax() {
+		return max.get();
+	}
+
+	/**
+	 * Specifies the maximum number reachable by the spinner.
+	 */
+	public ObjectProperty<T> maxProperty() {
+		return max;
+	}
+
+	public void setMax(T max) {
+		this.max.set(max);
+	}
+
+	public T getIncrement() {
+		return increment.get();
+	}
+
+	/**
+	 * Specifies the increment/decrement value to add/subtract from
+	 * the current value when calling {@link #next()} or {@link #previous()}.
+	 */
+	public ObjectProperty<T> incrementProperty() {
+		return increment;
+	}
+
+	public void setIncrement(T increment) {
+		this.increment.set(increment);
+	}
+}

+ 84 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/SpinnerModel.java

@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls.models.spinner;
+
+import io.github.palexdev.materialfx.controls.MFXSpinner;
+import javafx.beans.property.ObjectProperty;
+import javafx.util.StringConverter;
+
+/**
+ * Defines the public API for all models to be used with {@link MFXSpinner}.
+ * <p>
+ * {@code SpinnerModel} is basically an helper class to allow the spinner to work on any object
+ * type as long as a model exists for it. The model is responsible for changing the spinner's value by
+ * going forward or backwards ({@link #next() or {@link #previous()}}.
+ * <p>
+ * Along this core functionality the model also specifies a {@link StringConverter} which will be
+ * used to convert the T value to a String, which will be the spinner's text.
+ * <p>
+ * The spinner should also allow to cycle through the values, meaning that when reaching the the last value,
+ * {@link #next()} will go to the first value (and the other way around)
+ */
+public interface SpinnerModel<T> {
+
+	/**
+	 * Steps to the next value.
+	 */
+	void next();
+
+	/**
+	 * Steps to the previous value.
+	 */
+	void previous();
+
+	/**
+	 * Resets the spinner's value.
+	 */
+	void reset();
+
+	T getValue();
+
+	/**
+	 * Specifies the spinner's value.
+	 */
+	ObjectProperty<T> valueProperty();
+
+	void setValue(T value);
+
+	StringConverter<T> getConverter();
+
+	/**
+	 * Specifies the {@link StringConverter} used to convert the spinner value to a String.
+	 */
+	ObjectProperty<StringConverter<T>> converterProperty();
+
+	void setConverter(StringConverter<T> converter);
+
+	/**
+	 * @return whether the spinner can cycle through the values when
+	 * reaching the last/first value
+	 */
+	boolean isWrapAround();
+
+	/**
+	 * Sets whether the spinner can cycle through the values when
+	 * reaching the last/first value
+	 */
+	void setWrapAround(boolean wrapAround);
+}

+ 32 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/enums/ScaleBehavior.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.enums;
+
+public enum ScaleBehavior {
+	/**
+	 * Always scale the content.
+	 */
+	ALWAYS,
+
+	/**
+	 * Rescale content only if requested by the layout properties of the content
+	 * node.
+	 */
+	IF_NECESSARY
+}

+ 45 - 44
materialfx/src/main/java/io/github/palexdev/materialfx/font/FontResources.java

@@ -110,50 +110,51 @@ public enum FontResources {
 	MODENA_MARK("mfx-modena-mark", '\uE955'),
 	MUSIC("mfx-music", '\uE956'),
 	NEXT("mfx-next", '\uE957'),
-	PROGRESS_BARS("mfx-progress-bars", '\uE958'),
-	PROGRESS_BARS_ALT("mfx-progress-bars-alt", '\uE959'),
-	REDO("mfx-redo", '\uE95A'),
-	RESTORE("mfx-restore", '\uE95B'),
-	SCROLL_BAR("mfx-scroll-bar", '\uE95C'),
-	SEARCH("mfx-search", '\uE961'),
-	SEARCH_PLUS("mfx-search-plus", '\uE962'),
-	SELECT_ALL("mfx-select-all", '\uE963'),
-	SHORTCUT("mfx-shortcut", '\uE964'),
-	SIDEBAR_CLOSE("mfx-sidebar-close", '\uE965'),
-	SIDEBAR_OPEN("mfx-sidebar-open", '\uE966'),
-	SLIDERS("mfx-sliders", '\uE967'),
-	SPREADSHEET("mfx-spreadsheet", '\uE968'),
-	SQUARE_LIST("mfx-square-list", '\uE969'),
-	STEP_BACKWARD("mfx-step-backward", '\uE96A'),
-	STEP_FORWARD("mfx-step-forward", '\uE96B'),
-	STEPPER("mfx-stepper", '\uE96C'),
-	SYNC("mfx-sync", '\uE970'),
-	SYNC_LIGHT("mfx-sync-light", '\uE971'),
-	TABLE("mfx-table", '\uE972'),
-	TABLE_ALT("mfx-table-alt", '\uE973'),
-	TOGGLE_OFF("mfx-toggle-off", '\uE974'),
-	TOGGLE_ON("mfx-toggle-on", '\uE975'),
-	UNDO("mfx-undo", '\uE976'),
-	USER("mfx-user", '\uE977'),
-	USERS("mfx-users", '\uE978'),
-	VARIANT10_MARK("mfx-variant10-mark", '\uE979'),
-	VARIANT11_MARK("mfx-variant11-mark", '\uE97A'),
-	VARIANT12_MARK("mfx-variant12-mark", '\uE97B'),
-	VARIANT13_MARK("mfx-variant13-mark", '\uE97C'),
-	VARIANT14_MARK("mfx-variant14-mark", '\uE97D'),
-	VARIANT3_MARK("mfx-variant3-mark", '\uE97E'),
-	VARIANT4_MARK("mfx-variant4-mark", '\uE97F'),
-	VARIANT5_MARK("mfx-variant5-mark", '\uE980'),
-	VARIANT6_MARK("mfx-variant6-mark", '\uE981'),
-	VARIANT7_MARK("mfx-variant7-mark", '\uE982'),
-	VARIANT8_MARK("mfx-variant8-mark", '\uE983'),
-	VARIANT9_MARK("mfx-variant9-mark", '\uE984'),
-	VIDEO("mfx-video", '\uE985'),
-	X("mfx-x", '\uE986'),
-	X_ALT("mfx-x-alt", '\uE987'),
-	X_CIRCLE("mfx-x-circle", '\uE988'),
-	X_CIRCLE_LIGHT("mfx-x-circle-light", '\uE989'),
-	X_LIGHT("mfx-x-light", '\uE98A');
+	PLUS("mfx-plus", '\uE958'),
+	PROGRESS_BARS("mfx-progress-bars", '\uE959'),
+	PROGRESS_BARS_ALT("mfx-progress-bars-alt", '\uE95A'),
+	REDO("mfx-redo", '\uE95B'),
+	RESTORE("mfx-restore", '\uE95C'),
+	SCROLL_BAR("mfx-scroll-bar", '\uE95D'),
+	SEARCH("mfx-search", '\uE95E'),
+	SEARCH_PLUS("mfx-search-plus", '\uE95F'),
+	SELECT_ALL("mfx-select-all", '\uE960'),
+	SHORTCUT("mfx-shortcut", '\uE961'),
+	SIDEBAR_CLOSE("mfx-sidebar-close", '\uE962'),
+	SIDEBAR_OPEN("mfx-sidebar-open", '\uE963'),
+	SLIDERS("mfx-sliders", '\uE964'),
+	SPREADSHEET("mfx-spreadsheet", '\uE965'),
+	SQUARE_LIST("mfx-square-list", '\uE966'),
+	STEP_BACKWARD("mfx-step-backward", '\uE967'),
+	STEP_FORWARD("mfx-step-forward", '\uE968'),
+	STEPPER("mfx-stepper", '\uE969'),
+	SYNC("mfx-sync", '\uE96A'),
+	SYNC_LIGHT("mfx-sync-light", '\uE96B'),
+	TABLE("mfx-table", '\uE96C'),
+	TABLE_ALT("mfx-table-alt", '\uE96D'),
+	TOGGLE_OFF("mfx-toggle-off", '\uE96E'),
+	TOGGLE_ON("mfx-toggle-on", '\uE96F'),
+	UNDO("mfx-undo", '\uE970'),
+	USER("mfx-user", '\uE971'),
+	USERS("mfx-users", '\uE972'),
+	VARIANT10_MARK("mfx-variant10-mark", '\uE973'),
+	VARIANT11_MARK("mfx-variant11-mark", '\uE974'),
+	VARIANT12_MARK("mfx-variant12-mark", '\uE975'),
+	VARIANT13_MARK("mfx-variant13-mark", '\uE976'),
+	VARIANT14_MARK("mfx-variant14-mark", '\uE977'),
+	VARIANT3_MARK("mfx-variant3-mark", '\uE978'),
+	VARIANT4_MARK("mfx-variant4-mark", '\uE979'),
+	VARIANT5_MARK("mfx-variant5-mark", '\uE97A'),
+	VARIANT6_MARK("mfx-variant6-mark", '\uE97B'),
+	VARIANT7_MARK("mfx-variant7-mark", '\uE97C'),
+	VARIANT8_MARK("mfx-variant8-mark", '\uE97D'),
+	VARIANT9_MARK("mfx-variant9-mark", '\uE97E'),
+	VIDEO("mfx-video", '\uE97F'),
+	X("mfx-x", '\uE980'),
+	X_ALT("mfx-x-alt", '\uE981'),
+	X_CIRCLE("mfx-x-circle", '\uE982'),
+	X_CIRCLE_LIGHT("mfx-x-circle-light", '\uE983'),
+	X_LIGHT("mfx-x-light", '\uE984');
 
 	public static FontResources findByDescription(String description) {
 		for (FontResources font : values()) {

+ 397 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/layout/ScalableContentPane.java

@@ -0,0 +1,397 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.layout;
+
+import io.github.palexdev.materialfx.enums.ScaleBehavior;
+import javafx.beans.property.*;
+import javafx.beans.value.ChangeListener;
+import javafx.collections.ListChangeListener;
+import javafx.geometry.Bounds;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.layout.Pane;
+import javafx.scene.layout.Region;
+import javafx.scene.transform.Scale;
+import javafx.scene.transform.Transform;
+
+public class ScalableContentPane extends Region {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final ObjectProperty<Node> content = new SimpleObjectProperty<>();
+	private final DoubleProperty minScaleX = new SimpleDoubleProperty(Double.MIN_VALUE);
+	private final DoubleProperty maxScaleX = new SimpleDoubleProperty(Double.MAX_VALUE);
+	private final DoubleProperty minScaleY = new SimpleDoubleProperty(Double.MIN_VALUE);
+	private final DoubleProperty maxScaleY = new SimpleDoubleProperty(Double.MAX_VALUE);
+	private final BooleanProperty fitToWidth = new SimpleBooleanProperty(true);
+	private final BooleanProperty fitToHeight = new SimpleBooleanProperty(true);
+	private final ObjectProperty<ScaleBehavior> scaleBehavior = new SimpleObjectProperty<>(ScaleBehavior.ALWAYS);
+
+	private Scale scale;
+	private double scaleWidth;
+	private double scaleHeight;
+	private boolean aspectScale = true;
+	private boolean autoRescale = true;
+	private boolean manualReset;
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public ScalableContentPane() {
+		this(new Pane());
+	}
+
+	public ScalableContentPane(Node content) {
+		initialize();
+		setContent(content);
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+	private void initialize() {
+		needsLayoutProperty().addListener((observable, oldValue, newValue) -> {
+			boolean wCondition = getWidth() <= getPrefWidth();
+			boolean hCondition = getHeight() <= getPrefHeight();
+			boolean prefWCondition = getPrefWidth() == USE_COMPUTED_SIZE;
+			boolean prefHCondition = getPrefHeight() == USE_COMPUTED_SIZE;
+			if (newValue && (wCondition || hCondition) || prefWCondition || prefHCondition) computeScale();
+		});
+
+		fitToWidth.addListener((observable, oldValue, newValue) -> requestLayout());
+		fitToHeight.addListener((observable, oldValue, newValue) -> requestLayout());
+		scaleBehavior.addListener((observable, oldValue, newValue) -> requestLayout());
+		content.addListener((observable, oldValue, newValue) -> initializeContent());
+	}
+
+	private void initializeContent() {
+		ChangeListener<? super Bounds> boundsListener = (observable, oldValue, newValue) -> {
+			if (isAutoRescale()) {
+				Node cnt = getContent();
+				if (cnt instanceof Region) {
+					Region rgn = (Region) cnt;
+					rgn.requestLayout();
+				}
+				requestLayout();
+				setNeedsLayout(false);
+			}
+		};
+
+		ChangeListener<? super Number> layoutListener = (observable, oldValue, newValue) -> {
+			if (isAutoRescale()) {
+				Node cnt = getContent();
+				if (cnt instanceof Parent) {
+					Parent prt = (Parent) cnt;
+					prt.requestLayout();
+				}
+				requestLayout();
+				setNeedsLayout(false);
+			}
+		};
+
+		Node content = getContent();
+		if (getContent() instanceof Pane) {
+			Pane pane = (Pane) content;
+			ListChangeListener<? super Node> childrenListener = c -> {
+				while (c.next()) {
+					if (c.wasRemoved()) {
+						for (Node node : c.getRemoved()) {
+							node.boundsInLocalProperty().removeListener(boundsListener);
+							node.layoutXProperty().removeListener(layoutListener);
+							node.layoutYProperty().removeListener(layoutListener);
+						}
+					} else if (c.wasAdded()) {
+						for (Node node : c.getAddedSubList()) {
+							node.boundsInLocalProperty().addListener(boundsListener);
+							node.layoutXProperty().addListener(layoutListener);
+							node.layoutYProperty().addListener(layoutListener);
+						}
+					}
+				}
+			};
+			pane.getChildren().addListener(childrenListener);
+		}
+
+		scale = Transform.scale(1, 1, 0, 0);
+		content.getTransforms().add(scale);
+		getChildren().add(content);
+
+		ChangeListener<? super Number> scaleListener = (observable, oldValue, newValue) -> requestScale();
+		scale.setOnTransformChanged(event -> {
+			requestLayout();
+			setNeedsLayout(false);
+		});
+
+		minScaleX.addListener(scaleListener);
+		minScaleY.addListener(scaleListener);
+		maxScaleX.addListener(scaleListener);
+		maxScaleY.addListener(scaleListener);
+	}
+
+	private void computeScale() {
+		double realWidth = getContent().prefWidth(getLayoutBounds().getHeight());
+		double realHeight = getContent().prefHeight(getLayoutBounds().getWidth());
+		double leftAndRight = getInsets().getLeft() + getInsets().getRight();
+		double topAndBottom = getInsets().getTop() + getInsets().getBottom();
+
+		double contentWidth = getLayoutBounds().getWidth() - leftAndRight;
+		double contentHeight = getLayoutBounds().getHeight() - topAndBottom;
+
+		scaleWidth = contentWidth / realWidth;
+		scaleHeight = contentHeight / realHeight;
+
+		scaleWidth = Math.max(scaleWidth, getMinScaleX());
+		scaleWidth = Math.min(scaleWidth, getMaxScaleX());
+
+		scaleHeight = Math.max(scaleHeight, getMinScaleY());
+		scaleHeight = Math.min(scaleHeight, getMaxScaleY());
+
+		double resizeScaleW;
+		double resizeScaleH;
+
+		if (isAspectScale()) {
+			double scaleValue = Math.min(scaleWidth, scaleHeight);
+
+			if (getScaleBehavior() == ScaleBehavior.ALWAYS || manualReset) {
+				scale.setX(scaleValue);
+				scale.setY(scaleValue);
+			} else if (getScaleBehavior() == ScaleBehavior.IF_NECESSARY) {
+				if (scaleValue < scale.getX() && getLayoutBounds().getWidth() > 0) {
+					scale.setX(scaleValue);
+					scale.setY(scaleValue);
+				}
+			}
+
+		} else if (getScaleBehavior() == ScaleBehavior.ALWAYS || manualReset) {
+			scale.setX(scaleWidth);
+			scale.setY(scaleHeight);
+		} else if (getScaleBehavior() == ScaleBehavior.IF_NECESSARY) {
+			if (scaleWidth < scale.getX() && getLayoutBounds().getWidth() > 0) {
+				scale.setX(scaleWidth);
+			}
+			if (scaleHeight < scale.getY() && getLayoutBounds().getHeight() > 0) {
+				scale.setY(scaleHeight);
+			}
+		}
+
+		resizeScaleW = scale.getX();
+		resizeScaleH = scale.getY();
+
+		getContent().relocate(getInsets().getLeft(), getInsets().getTop());
+
+		double realContentWidth;
+		double realContentHeight;
+
+		if (isFitToWidth()) {
+			realContentWidth = contentWidth / resizeScaleW;
+		} else {
+			realContentWidth = contentWidth / scaleWidth;
+		}
+
+		if (isFitToHeight()) {
+			realContentHeight = contentHeight / resizeScaleH;
+		} else {
+			realContentHeight = contentHeight / scaleHeight;
+		}
+
+		getContent().resize(realContentWidth, realContentHeight);
+	}
+
+	public void requestScale() {
+		computeScale();
+	}
+
+	public void resetScale() {
+		if (manualReset) {
+			return;
+		}
+
+		manualReset = true;
+
+		try {
+			computeScale();
+		} finally {
+			manualReset = false;
+		}
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected double computeMinWidth(double height) {
+		double result = getInsets().getLeft() + getInsets().getRight();
+
+		// apply content width (including scale)
+		result += getContent().prefWidth(height) * getMinScaleX();
+		return result;
+	}
+
+	@Override
+	protected double computeMinHeight(double width) {
+		double result = getInsets().getTop() + getInsets().getBottom();
+
+		// apply content width (including scale)
+		result += getContent().prefHeight(width) * getMinScaleY();
+		return result;
+	}
+
+	@Override
+	protected double computePrefWidth(double height) {
+		double result = getInsets().getLeft() + getInsets().getRight();
+
+		// apply content width (including scale)
+		result += getContent().prefWidth(height) * scaleWidth;
+		return result;
+	}
+
+	@Override
+	protected double computePrefHeight(double width) {
+		double result = getInsets().getTop() + getInsets().getBottom();
+
+		// apply content width (including scale)
+		result += getContent().prefHeight(width) * scaleHeight;
+		return result;
+	}
+
+	@Override
+	protected void layoutChildren() {
+		super.layoutChildren();
+		computeScale();
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public Node getContent() {
+		return content.get();
+	}
+
+	public ObjectProperty<Node> contentProperty() {
+		return content;
+	}
+
+	public void setContent(Node content) {
+		this.content.set(content);
+	}
+
+	public double getMinScaleX() {
+		return minScaleX.get();
+	}
+
+	public DoubleProperty minScaleXProperty() {
+		return minScaleX;
+	}
+
+	public void setMinScaleX(double minScaleX) {
+		this.minScaleX.set(minScaleX);
+	}
+
+	public double getMaxScaleX() {
+		return maxScaleX.get();
+	}
+
+	public DoubleProperty maxScaleXProperty() {
+		return maxScaleX;
+	}
+
+	public void setMaxScaleX(double maxScaleX) {
+		this.maxScaleX.set(maxScaleX);
+	}
+
+	public double getMinScaleY() {
+		return minScaleY.get();
+	}
+
+	public DoubleProperty minScaleYProperty() {
+		return minScaleY;
+	}
+
+	public void setMinScaleY(double minScaleY) {
+		this.minScaleY.set(minScaleY);
+	}
+
+	public double getMaxScaleY() {
+		return maxScaleY.get();
+	}
+
+	public DoubleProperty maxScaleYProperty() {
+		return maxScaleY;
+	}
+
+	public void setMaxScaleY(double maxScaleY) {
+		this.maxScaleY.set(maxScaleY);
+	}
+
+	public boolean isFitToWidth() {
+		return fitToWidth.get();
+	}
+
+	public BooleanProperty fitToWidthProperty() {
+		return fitToWidth;
+	}
+
+	public void setFitToWidth(boolean fitToWidth) {
+		this.fitToWidth.set(fitToWidth);
+	}
+
+	public boolean isFitToHeight() {
+		return fitToHeight.get();
+	}
+
+	public BooleanProperty fitToHeightProperty() {
+		return fitToHeight;
+	}
+
+	public void setFitToHeight(boolean fitToHeight) {
+		this.fitToHeight.set(fitToHeight);
+	}
+
+	public ScaleBehavior getScaleBehavior() {
+		return scaleBehavior.get();
+	}
+
+	public ObjectProperty<ScaleBehavior> scaleBehaviorProperty() {
+		return scaleBehavior;
+	}
+
+	public void setScaleBehavior(ScaleBehavior scaleBehavior) {
+		this.scaleBehavior.set(scaleBehavior);
+	}
+
+	public Scale getScale() {
+		return scale;
+	}
+
+	public boolean isAspectScale() {
+		return aspectScale;
+	}
+
+	public void setAspectScale(boolean aspectScale) {
+		this.aspectScale = aspectScale;
+	}
+
+	public boolean isAutoRescale() {
+		return autoRescale;
+	}
+
+	public void setAutoRescale(boolean autoRescale) {
+		this.autoRescale = autoRescale;
+	}
+}

+ 307 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXSpinnerSkin.java

@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.skins;
+
+import io.github.palexdev.materialfx.controls.MFXSpinner;
+import io.github.palexdev.materialfx.controls.MFXTextField;
+import io.github.palexdev.materialfx.controls.models.spinner.SpinnerModel;
+import io.github.palexdev.materialfx.enums.FloatMode;
+import io.github.palexdev.materialfx.factories.InsetsFactory;
+import io.github.palexdev.materialfx.utils.NodeUtils;
+import io.github.palexdev.materialfx.utils.TextUtils;
+import javafx.beans.binding.Bindings;
+import javafx.beans.binding.StringBinding;
+import javafx.beans.value.ChangeListener;
+import javafx.css.PseudoClass;
+import javafx.geometry.Orientation;
+import javafx.scene.Node;
+import javafx.scene.control.SkinBase;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.util.StringConverter;
+
+import java.util.function.BiFunction;
+
+/**
+ * This is the default skin implementation for {@link MFXSpinner}.
+ * <p>
+ * There are two main components:
+ * <p> 1) The top container, a {@link BorderPane}, which contains the spinner's text field
+ * and the two icons. The usage of a {@code BorderPane} simplifies a lot the layout since it's
+ * everything automatically handled. This allows to easily switch the spinner's orientation,
+ * {@link MFXSpinner#orientationProperty()}, and handle the icons.
+ * <p> 2) A {@link MFXTextField} to show the spinner's selected value as text. Here things are a bit
+ * more complicated. The field's text is not simply bound but it's computed by a {@link StringBinding}.
+ * This is needed because there are several things to consider since the spinner relies on {@link SpinnerModel}
+ * to handle the selected value and also the way to convert the value to text.
+ * It's needed, for example, to keep track of both the {@link MFXSpinner#spinnerModelProperty()} and
+ * the {@link SpinnerModel#converterProperty()} at the same time.
+ * <p></p>
+ * The skin also introduces a new {@link PseudoClass}: ":focus-within", it is activated when
+ * the text field is focused or when one of the icons is focused.
+ */
+public class MFXSpinnerSkin<T> extends SkinBase<MFXSpinner<T>> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final BorderPane container;
+	private final MFXTextField field;
+	private Node nextIcon;
+	private Node prevIcon;
+
+	private ChangeListener<SpinnerModel<T>> modelListener;
+	private ChangeListener<StringConverter<T>> converterListener;
+	private StringBinding textBinding;
+
+	private static final PseudoClass FOCUS_WITHIN_PSEUDO_CLASS = PseudoClass.getPseudoClass("focus-within");
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public MFXSpinnerSkin(MFXSpinner<T> spinner) {
+		super(spinner);
+
+		// Text Field
+		field = new MFXTextField() {
+			@Override
+			public String getUserAgentStylesheet() {
+				return spinner.getUserAgentStylesheet();
+			}
+		};
+		field.setFloatMode(FloatMode.DISABLED);
+		field.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
+		field.allowEditProperty().bind(spinner.editableProperty());
+		field.selectableProperty().bind(spinner.selectableProperty());
+		field.promptTextProperty().bind(spinner.promptTextProperty());
+
+		textBinding = Bindings.createStringBinding(
+				() -> {
+					T value = spinner.getValue();
+					SpinnerModel<T> model = spinner.getSpinnerModel();
+					String s;
+					if (model == null) {
+						s = value != null ? value.toString() : "";
+					} else {
+						s = model.getConverter().toString(value);
+					}
+
+					BiFunction<Boolean, String, String> textTransformer = spinner.getTextTransformer();
+					return (textTransformer != null) ? textTransformer.apply(field.delegateIsFocused(), s) : s;
+				},
+				spinner.valueProperty(), spinner.spinnerModelProperty(), field.delegateFocusedProperty()
+		);
+		if (textBinding.get() != null && !textBinding.get().isEmpty()) field.setText(textBinding.get());
+
+		// Model Listeners
+		modelListener = (observable, oldValue, newValue) -> initializeModel(oldValue, newValue);
+		converterListener = (observable, oldValue, newValue) -> textBinding.invalidate();
+
+		// Icons Initialization
+		nextIcon = (spinner.getNextIconSupplier() != null) ? spinner.getNextIconSupplier().get() : null;
+		prevIcon = (spinner.getPrevIconSupplier() != null) ? spinner.getPrevIconSupplier().get() : null;
+
+		// Top Container Initialization
+		container = new BorderPane();
+		manageContainer();
+		manageGap();
+
+		// Skin Initialization
+		getChildren().setAll(container);
+		addListeners();
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+
+	/**
+	 * Adds the following listeners/handlers:
+	 * <p> - A listener to the {@link MFXSpinner#spinnerModelProperty()} to call {@link #initializeModel(SpinnerModel, SpinnerModel)} when it changes
+	 * <p> - A listener to update the field's text
+	 * <p> - A listener to activate the ":focus-within" PseudoClass when the text field is focused
+	 * <p> - A listener to manage the {@link MFXSpinner#orientationProperty()}
+	 * <p> - A listener to manage the {@link MFXSpinner#graphicTextGapProperty()}
+	 * <p> - Two listeners to handle {@link MFXSpinner#prevIconSupplierProperty()} and {@link MFXSpinner#nextIconSupplierProperty()}
+	 * <p> - A MOUSE_PRESSED handler to handle the spinner's focus and also activate the ":focus-within" PseudoClass if needed
+	 * <p> - A KEY_PRESSED filter to handle the edit/cancel features. Pressing ENTER will trigger the {@link MFXSpinner#commit(String)}
+	 * method. Pressing Ctrl+Shift+Z will cancel the edit and reset the text for the current selected value
+	 */
+	private void addListeners() {
+		MFXSpinner<T> spinner = getSkinnable();
+
+		spinner.spinnerModelProperty().addListener(modelListener);
+		textBinding.addListener(invalidated -> field.setText(textBinding.getValue()));
+		field.delegateFocusedProperty().addListener((observable, oldValue, newValue) -> spinner.pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, newValue));
+
+		spinner.orientationProperty().addListener(invalidated -> manageContainer());
+		spinner.graphicTextGapProperty().addListener(invalidated -> manageGap());
+		spinner.prevIconSupplierProperty().addListener((observable, oldValue, newValue) -> {
+			if (newValue != null) {
+				prevIcon = newValue.get();
+			} else {
+				prevIcon = null;
+			}
+			manageContainer();
+		});
+		spinner.nextIconSupplierProperty().addListener((observable, oldValue, newValue) -> {
+			if (newValue != null) {
+				nextIcon = newValue.get();
+			} else {
+				nextIcon = null;
+			}
+			manageContainer();
+		});
+
+		spinner.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+			if (NodeUtils.inHierarchy(event, nextIcon) || NodeUtils.inHierarchy(event, prevIcon)) {
+				boolean iconsFocused = (nextIcon != null && nextIcon.isFocused()) || (prevIcon != null && prevIcon.isFocused());
+				spinner.pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, iconsFocused);
+				return;
+			}
+			spinner.pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, false);
+			spinner.requestFocus();
+		});
+		spinner.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
+			switch (event.getCode()) {
+				case ENTER: {
+					spinner.commit(field.getText());
+					break;
+				}
+				case Z: {
+					if (event.isControlDown() && event.isShiftDown()) {
+						SpinnerModel<T> model = spinner.getSpinnerModel();
+						if (model != null) {
+							String s = model.getConverter().toString(spinner.getValue());
+							field.setText(s);
+						}
+					}
+					break;
+				}
+			}
+		});
+	}
+
+	/**
+	 * Manages the top container, {@link BorderPane}, according to the spinner's current
+	 * {@link Orientation}.
+	 * <p>
+	 * Also calls {@link #manageGap()}.
+	 */
+	private void manageContainer() {
+		MFXSpinner<T> spinner = getSkinnable();
+		Orientation orientation = spinner.getOrientation();
+
+		container.getChildren().clear();
+		if (orientation == Orientation.HORIZONTAL) {
+			if (prevIcon != null) container.setLeft(prevIcon);
+			if (nextIcon != null) container.setRight(nextIcon);
+		} else {
+			if (nextIcon != null) container.setTop(nextIcon);
+			if (prevIcon != null) container.setBottom(prevIcon);
+		}
+		container.setCenter(field);
+		manageGap();
+	}
+
+	/**
+	 * Responsible for applying the {@link MFXSpinner#graphicTextGapProperty()} to the
+	 * the spinner's icons.
+	 */
+	private void manageGap() {
+		MFXSpinner<T> spinner = getSkinnable();
+		Orientation orientation = spinner.getOrientation();
+		double gap = spinner.getGraphicTextGap();
+
+		if (orientation == Orientation.HORIZONTAL) {
+			if (prevIcon != null) BorderPane.setMargin(prevIcon, InsetsFactory.right(gap));
+			if (nextIcon != null) BorderPane.setMargin(nextIcon, InsetsFactory.left(gap));
+		} else {
+			if (prevIcon != null) BorderPane.setMargin(prevIcon, InsetsFactory.top(gap));
+			if (nextIcon != null) BorderPane.setMargin(nextIcon, InsetsFactory.bottom(gap));
+		}
+	}
+
+	/**
+	 * Responsible for adding a listener to the spinner's {@link SpinnerModel#converterProperty()}
+	 * when it changes.
+	 * <p></p>
+	 * The listener is responsible for updating the text when the converter changes.
+	 */
+	private void initializeModel(SpinnerModel<T> oldModel, SpinnerModel<T> newModel) {
+		if (oldModel != newModel) {
+			oldModel.converterProperty().removeListener(converterListener);
+		}
+		if (newModel != null) {
+			newModel.converterProperty().addListener(converterListener);
+		}
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		return super.computeMinWidth(height, topInset, rightInset, bottomInset, leftInset) + 5;
+	}
+
+	@Override
+	protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		MFXSpinner<T> spinner = getSkinnable();
+		Orientation orientation = spinner.getOrientation();
+		double gap = spinner.getGraphicTextGap();
+
+		double textW = field.snappedLeftInset() + Math.max(
+				TextUtils.computeTextWidth(field.getFont(), field.getText()),
+				TextUtils.computeTextWidth(field.getFont(), field.getPromptText())
+		) + field.snappedRightInset();
+		double prevIconW = prevIcon.prefWidth(-1);
+		double nextIconW = nextIcon.prefWidth(-1);
+
+		if (orientation == Orientation.HORIZONTAL) {
+			return leftInset +
+					(prevIcon != null ? prevIconW + gap : 0) +
+					textW +
+					(nextIcon != null ? nextIconW + gap : 0) +
+					rightInset;
+		} else {
+			return leftInset +
+					Math.max(textW, Math.max(prevIconW, nextIconW)) +
+					rightInset;
+		}
+	}
+
+	@Override
+	protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		return getSkinnable().prefWidth(-1);
+	}
+
+	@Override
+	protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
+		return getSkinnable().prefHeight(-1);
+	}
+
+	@Override
+	public void dispose() {
+		super.dispose();
+		textBinding = null;
+		modelListener = null;
+		converterListener = null;
+	}
+}

+ 2 - 2
materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXTextFieldSkin.java

@@ -63,7 +63,7 @@ public class MFXTextFieldSkin extends SkinBase<MFXTextField> {
 	private final BoundTextField boundField;
 	private final Label floatingText;
 
-	private static final PseudoClass FOCUSED_PSEUDO_CLASS = PseudoClass.getPseudoClass("focused");
+	private static final PseudoClass FOCUS_WITHIN_PSEUDO_CLASS = PseudoClass.getPseudoClass("focus-within");
 
 	private final ObjectProperty<PositionBean> floatingPos = new SimpleObjectProperty<>(PositionBean.of(0, 0));
 	private final BooleanExpression floating;
@@ -148,7 +148,7 @@ public class MFXTextFieldSkin extends SkinBase<MFXTextField> {
 		textField.focusedProperty().addListener((observable, oldValue, newValue) -> boundField.requestFocus());
 		boundField.focusedProperty().addListener((observable, oldValue, newValue) -> {
 			textField.requestLayout();
-			pseudoClassStateChanged(FOCUSED_PSEUDO_CLASS, newValue);
+			pseudoClassStateChanged(FOCUS_WITHIN_PSEUDO_CLASS, newValue);
 		});
 
 		// Icons

+ 3 - 3
materialfx/src/main/java/io/github/palexdev/materialfx/utils/ListChangeProcessor.java

@@ -84,7 +84,7 @@ public class ListChangeProcessor {
 				tmp.add(i);
 				continue;
 			}
-			int index = i - findShift(removed, i);
+			int index = Math.max(i - findShift(removed, i), 0);
 			tmp.add(index);
 		}
 		indexes = tmp;
@@ -92,10 +92,10 @@ public class ListChangeProcessor {
 
 	/**
 	 * Iterates over the given Set of removed indexes to count the number
-	 * of indexes that are lesser than the given index.
+	 * of indexes that are lesser or equal to the given index.
 	 */
 	private int findShift(Set<Integer> removed, int index) {
-		return (int) removed.stream().filter(i -> i < index).count();
+		return (int) removed.stream().filter(i -> i <= index).count();
 	}
 
 	//================================================================================

+ 1 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnChanged.java

@@ -88,6 +88,7 @@ public class OnChanged<T> extends When<T> {
 	 * then adds the listener to the specified {@link ObservableValue} and finally puts the Observable and
 	 * the OnChanged construct in the map.
 	 */
+	@Override
 	public OnChanged<T> listen() {
 		if (oneShot) {
 			listener = (observable, oldValue, newValue) -> {

+ 1 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/utils/others/observables/OnInvalidated.java

@@ -88,6 +88,7 @@ public class OnInvalidated<T> extends When<T> {
 	 * then adds the listener to the specified {@link ObservableValue} and finally puts the Observable and
 	 * the OnInvalidated construct in the map.
 	 */
+	@Override
 	public OnInvalidated<T> listen() {
 		if (oneShot) {
 			listener = invalidated -> {

+ 4 - 0
materialfx/src/main/java/module-info.java

@@ -34,6 +34,7 @@ module MaterialFX {
 	exports io.github.palexdev.materialfx.controls.base;
 	exports io.github.palexdev.materialfx.controls.cell;
 	exports io.github.palexdev.materialfx.controls.legacy;
+	exports io.github.palexdev.materialfx.controls.models.spinner;
 
 	// CSS Package
 	exports io.github.palexdev.materialfx.css;
@@ -62,6 +63,9 @@ module MaterialFX {
 	// I18N Package
 	exports io.github.palexdev.materialfx.i18n;
 
+	// Layout Package
+	exports io.github.palexdev.materialfx.layout;
+
 	// Notifications Package
 	exports io.github.palexdev.materialfx.notifications;
 	exports io.github.palexdev.materialfx.notifications.base;

+ 66 - 0
materialfx/src/main/resources/io/github/palexdev/materialfx/css/MFXSpinner.css

@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+@import "MFXColors.css";
+@import "MFXTextField.css";
+
+.mfx-spinner {
+	-fx-background-color: white;
+	-fx-background-radius: 5;
+	-fx-border-color: lightgray;
+	-fx-border-radius: 5;
+	-fx-padding: 5 10 5 10;
+}
+
+.mfx-spinner:focused,
+.mfx-spinner:focus-within {
+	-fx-border-color: -mfx-purple;
+}
+
+.mfx-spinner .mfx-text-field {
+	-fx-background-insets: 0;
+	-fx-border-color: transparent;
+	-fx-border-width: 1;
+	-fx-border-insets: 0;
+	-fx-padding: 5 10 5 10;
+}
+
+.mfx-spinner .mfx-icon-wrapper {
+	-fx-background-color: derive(-mfx-purple, 155%);
+	-mfx-size: 27;
+}
+
+.mfx-spinner .mfx-icon-wrapper:focused,
+.mfx-spinner .mfx-icon-wrapper:hover {
+	-fx-background-color: derive(-mfx-purple, 50%);
+}
+
+.mfx-spinner .mfx-icon-wrapper:focused .mfx-font-icon,
+.mfx-spinner .mfx-icon-wrapper:hover .mfx-font-icon {
+	-mfx-color: white;
+}
+
+.mfx-spinner .mfx-icon-wrapper .mfx-font-icon {
+	-mfx-color: -mfx-purple;
+	-mfx-size: 14;
+}
+
+.mfx-spinner .mfx-icon-wrapper .mfx-ripple-generator {
+	-mfx-ripple-radius: 24;
+	-mfx-ripple-color: rgba(255, 255, 255, 0.5);
+}

BIN
materialfx/src/main/resources/io/github/palexdev/materialfx/fonts/MFXResources.ttf