diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/YearPickerApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/YearPickerApp.java new file mode 100644 index 00000000..d80e2bd4 --- /dev/null +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/YearPickerApp.java @@ -0,0 +1,48 @@ +package com.dlsc.gemsfx.demo; + +import com.dlsc.gemsfx.YearPicker; +import fr.brouillard.oss.cssfx.CSSFX; +import javafx.application.Application; +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +public class YearPickerApp extends Application { + + @Override + public void start(Stage stage) { + YearPicker yearPicker = new YearPicker(); + + Label valueLabel = new Label(); + valueLabel.textProperty().bind(Bindings.convert(yearPicker.yearProperty())); + + CheckBox editable = new CheckBox("Editable"); + editable.selectedProperty().bindBidirectional(yearPicker.editableProperty()); + + CheckBox disable = new CheckBox("Disable"); + disable.selectedProperty().bindBidirectional(yearPicker.disableProperty()); + + VBox vBox = new VBox(10, yearPicker, valueLabel, editable, disable); + + vBox.setPadding(new Insets(20)); + vBox.setAlignment(Pos.TOP_LEFT); + + Scene scene = new Scene(vBox); + CSSFX.start(); + + stage.setTitle("YearPicker"); + stage.setScene(scene); + stage.sizeToScene(); + stage.centerOnScreen(); + stage.show(); + } + + public static void main(String[] args) { + launch(); + } +} diff --git a/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/YearViewApp.java b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/YearViewApp.java new file mode 100644 index 00000000..10514439 --- /dev/null +++ b/gemsfx-demo/src/main/java/com/dlsc/gemsfx/demo/YearViewApp.java @@ -0,0 +1,38 @@ +package com.dlsc.gemsfx.demo; + +import com.dlsc.gemsfx.YearView; +import fr.brouillard.oss.cssfx.CSSFX; +import javafx.application.Application; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +public class YearViewApp extends Application { + + @Override + public void start(Stage stage) { + YearView view = new YearView(); + view.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + + VBox vBox = new VBox(view); + + vBox.setPadding(new Insets(20)); + vBox.setAlignment(Pos.CENTER); + + Scene scene = new Scene(vBox); + CSSFX.start(); + + stage.setTitle("YearView"); + stage.setScene(scene); + stage.sizeToScene(); + stage.centerOnScreen(); + stage.show(); + } + + public static void main(String[] args) { + launch(); + } +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/YearPicker.java b/gemsfx/src/main/java/com/dlsc/gemsfx/YearPicker.java new file mode 100644 index 00000000..32f32071 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/YearPicker.java @@ -0,0 +1,145 @@ +package com.dlsc.gemsfx; + +import com.dlsc.gemsfx.skins.YearPickerSkin; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.css.PseudoClass; +import javafx.scene.control.ComboBoxBase; +import javafx.scene.control.Skin; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.Region; +import javafx.util.converter.NumberStringConverter; +import org.apache.commons.lang3.StringUtils; + +import java.text.DecimalFormat; +import java.text.ParsePosition; +import java.time.Year; +import java.util.function.UnaryOperator; + +public class YearPicker extends ComboBoxBase { + + private final TextField editor = new TextField(); + private final NumberStringFilteredConverter converter = new NumberStringFilteredConverter(); + + public YearPicker() { + getStyleClass().setAll("year-picker", "text-input"); + + setFocusTraversable(false); + + valueProperty().addListener((obs, oldV, newV) -> { + updateText(newV); + year.set(newV == null ? null : newV.getValue()); + }); + + editor.setTextFormatter(new TextFormatter<>(converter, null, converter.getFilter())); + editor.editableProperty().bind(editableProperty()); + editor.setOnAction(evt -> commit()); + editor.focusedProperty().addListener(it -> { + if (!editor.isFocused()) { + commit(); + } + pseudoClassStateChanged(PseudoClass.getPseudoClass("focused"), editor.isFocused()); + }); + + editor.addEventHandler(KeyEvent.KEY_PRESSED, evt -> { + Year value = getValue(); + if (value != null) { + if (evt.getCode().equals(KeyCode.DOWN)) { + setValue(value.plusYears(1)); + } else if (evt.getCode().equals(KeyCode.UP)) { + setValue(value.minusYears(1)); + } + } + }); + + setMaxWidth(Region.USE_PREF_SIZE); + updateText(null); + } + + /** + * Returns the text field control used for manual input. + * + * @return the editor / text field + */ + public final TextField getEditor() { + return editor; + } + + @Override + protected Skin createDefaultSkin() { + return new YearPickerSkin(this); + } + + @Override + public String getUserAgentStylesheet() { + return YearMonthView.class.getResource("year-picker.css").toExternalForm(); + } + + private final ReadOnlyObjectWrapper year = new ReadOnlyObjectWrapper<>(this, "wrapper"); + + public final ReadOnlyObjectProperty yearProperty() { + return year.getReadOnlyProperty(); + } + + public final Integer getYear() { + return year.get(); + } + + private void commit() { + String text = editor.getText(); + if (StringUtils.isNotBlank(text)) { + Number value = converter.fromString(text); + if (value != null) { + setValue(Year.of(value.intValue())); + } else { + setValue(null); + } + } + } + + private void updateText(Year value) { + if (value != null) { + editor.setText(String.valueOf(value.getValue())); + } else { + editor.setText(""); + } + editor.positionCaret(editor.getText().length()); + } + + static class NumberStringFilteredConverter extends NumberStringConverter { + + public NumberStringFilteredConverter() { + super(new DecimalFormat("####")); + } + + UnaryOperator getFilter() { + return change -> { + String newText = change.getControlNewText(); + + if (!newText.isEmpty()) { + // Convert to number + ParsePosition parsePosition = new ParsePosition(0); + Number value = getNumberFormat().parse(newText, parsePosition); + if (value == null || parsePosition.getIndex() < newText.length()) { + return null; + } + + // Validate max length + if (newText.length() > 4) { + String head = change.getControlNewText().substring(0, 4); + change.setText(head); + int oldLength = change.getControlText().length(); + change.setRange(0, oldLength); + } + } + + return change; + }; + } + + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/YearView.java b/gemsfx/src/main/java/com/dlsc/gemsfx/YearView.java new file mode 100644 index 00000000..8363fd6b --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/YearView.java @@ -0,0 +1,41 @@ +package com.dlsc.gemsfx; + +import com.dlsc.gemsfx.skins.YearViewSkin; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; + +import java.time.Year; + +public class YearView extends Control { + + public YearView() { + getStyleClass().add("year-view"); + } + + @Override + protected Skin createDefaultSkin() { + return new YearViewSkin(this); + } + + @Override + public String getUserAgentStylesheet() { + return YearView.class.getResource("year-view.css").toExternalForm(); + } + + private final ObjectProperty value = new SimpleObjectProperty<>(this, "value"); + + public Year getValue() { + return value.get(); + } + + public ObjectProperty valueProperty() { + return value; + } + + public void setValue(Year value) { + this.value.set(value); + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/YearPickerSkin.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/YearPickerSkin.java new file mode 100644 index 00000000..2bf64177 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/YearPickerSkin.java @@ -0,0 +1,54 @@ +package com.dlsc.gemsfx.skins; + +import com.dlsc.gemsfx.YearPicker; +import com.dlsc.gemsfx.YearView; +import javafx.scene.Node; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.Objects; + +public class YearPickerSkin extends CustomComboBoxSkinBase { + + private YearView yearView; + + public YearPickerSkin(YearPicker picker) { + super(picker); + + picker.setOnMouseClicked(evt -> picker.show()); + + FontIcon calendarIcon = new FontIcon(); + calendarIcon.getStyleClass().add("edit-icon"); // using styles similar to combobox, for consistency + + StackPane editButton = new StackPane(calendarIcon); + editButton.setFocusTraversable(false); + editButton.setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + editButton.getStyleClass().add("edit-button"); // using styles similar to combobox, for consistency + editButton.setOnMouseClicked(evt -> picker.show()); + + HBox.setHgrow(picker.getEditor(), Priority.ALWAYS); + + HBox box = new HBox(picker.getEditor(), editButton); + box.getStyleClass().add("box"); + + getChildren().add(box); + } + + @Override + protected Node getPopupContent() { + if (yearView == null) { + yearView = new YearView(); + yearView.valueProperty().bindBidirectional(getSkinnable().valueProperty()); + yearView.valueProperty().addListener((obs, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + getSkinnable().hide(); + } + }); + } + return yearView; + } + +} diff --git a/gemsfx/src/main/java/com/dlsc/gemsfx/skins/YearViewSkin.java b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/YearViewSkin.java new file mode 100644 index 00000000..a019da12 --- /dev/null +++ b/gemsfx/src/main/java/com/dlsc/gemsfx/skins/YearViewSkin.java @@ -0,0 +1,123 @@ +package com.dlsc.gemsfx.skins; + +import com.dlsc.gemsfx.YearView; +import javafx.scene.control.Label; +import javafx.scene.control.SkinBase; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; + +import java.time.LocalDate; +import java.time.Year; +import java.util.Optional; + +public class YearViewSkin extends SkinBase { + + private static final int ROWS = 5; + private static final int COLUMNS = 4; + + private final Label yearRangeLabel; + private final HBox header; + private final GridPane gridPane; + + private int offset = 0; + + public YearViewSkin(YearView yearView) { + super(yearView); + + yearRangeLabel = new Label(); + yearRangeLabel.getStyleClass().add("year-range-label"); + yearRangeLabel.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + HBox.setHgrow(yearRangeLabel, Priority.ALWAYS); + + Region leftArrow = new Region(); + leftArrow.getStyleClass().addAll("arrow", "left-arrow"); + + Region rightArrow = new Region(); + rightArrow.getStyleClass().addAll("arrow", "right-arrow"); + + StackPane leftArrowButton = new StackPane(leftArrow); + leftArrowButton.getStyleClass().addAll("arrow-button", "left-button"); + leftArrowButton.setOnMouseClicked(evt -> { + offset--; + buildGrid(); + }); + + StackPane rightArrowButton = new StackPane(rightArrow); + rightArrowButton.getStyleClass().addAll("arrow-button", "right-button"); + rightArrowButton.setOnMouseClicked(evt -> { + offset++; + buildGrid(); + }); + + header = new HBox(leftArrowButton, yearRangeLabel, rightArrowButton); + header.getStyleClass().add("header"); + header.setViewOrder(Double.NEGATIVE_INFINITY); + + ColumnConstraints col1 = new ColumnConstraints(); + col1.setPercentWidth(25); + ColumnConstraints col2 = new ColumnConstraints(); + col2.setPercentWidth(25); + ColumnConstraints col3 = new ColumnConstraints(); + col3.setPercentWidth(25); + ColumnConstraints col4 = new ColumnConstraints(); + col4.setPercentWidth(25); + + gridPane = new GridPane(); + gridPane.getStyleClass().add("grid-pane"); + gridPane.getColumnConstraints().addAll(col1, col2, col3, col4); + + getChildren().addAll(header, gridPane); + + yearView.valueProperty().addListener(obs -> buildGrid()); + buildGrid(); + } + + @Override + protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) { + double headerHeight = snapSizeY(header.prefHeight(-1)); + header.resizeRelocate(contentX, contentY, contentWidth, headerHeight); + gridPane.resizeRelocate(contentX, contentY + headerHeight, contentWidth, contentHeight - headerHeight); + } + + private void buildGrid() { + final int visibleYears = ROWS * COLUMNS; + + Year selectedYear = getSkinnable().getValue(); + int currentYear = LocalDate.now().getYear(); + int firstYear = ((Optional.ofNullable(selectedYear).map(Year::getValue).orElse(currentYear) / visibleYears) * visibleYears) + (offset * visibleYears); + + gridPane.getChildren().clear(); + yearRangeLabel.setText(firstYear + "-" + (firstYear + visibleYears - 1)); + + for (int row = 0; row < ROWS; row++) { + for (int column = 0; column < COLUMNS; column++) { + final int finalYear = firstYear; + + Label yearLabel = new Label(); + yearLabel.getStyleClass().add("year"); + yearLabel.setText(String.valueOf(firstYear)); + yearLabel.setOnMouseClicked(evt -> { + offset = 0; + getSkinnable().setValue(Year.of(finalYear)); + }); + + if (selectedYear != null && firstYear == selectedYear.getValue()) { + yearLabel.getStyleClass().add("selected"); + } + + if (firstYear == currentYear) { + yearLabel.getStyleClass().add("current"); + } + + gridPane.add(yearLabel, column, row); + firstYear++; + } + } + + } + +} diff --git a/gemsfx/src/main/resources/com/dlsc/gemsfx/year-picker.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/year-picker.css new file mode 100644 index 00000000..3826083a --- /dev/null +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/year-picker.css @@ -0,0 +1,45 @@ +.year-picker { + -fx-padding: 0px; + -fx-pref-height: 2.1em; +} + +.year-picker .year-view { + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.2), 12, 0.0, 0, 8); +} + +.year-picker > .box { + -fx-alignment: center; + -fx-spacing: 0px; +} + +.year-picker > .box > .spacer { + -fx-pref-width: 0px; +} + +.year-picker > .box > .edit-button { + -fx-pref-width: 27px; + -fx-padding: 5px 5px; + -fx-cursor: arrow; + -fx-background-color: -fx-outer-border, -fx-inner-border, -fx-body-color; + -fx-background-insets: 1 1 1 0, 1, 2; + -fx-background-radius: 0 3 3 0, 0 2 2 0, 0 1 1 0; +} + +.year-picker:focused > .box > .edit-button { + -fx-background-color: -fx-focus-color, -fx-inner-border, -fx-body-color, -fx-faint-focus-color, -fx-body-color; + -fx-background-insets: 0, 1, 2, 1, 2.6; + -fx-background-radius: 0 2 2 0, 0 1 1 0, 0 1 1 0, 0 1 1 0; + -fx-border-color: -fx-faint-focus-color; + -fx-border-width: 0 0 0 1; + -fx-border-insets: 0 0 0 -1; +} + +.year-picker > .box > .edit-button > .ikonli-font-icon { + -fx-icon-code: mdi-calendar; + -fx-icon-size: 14px; + -fx-icon-color: -fx-mark-color; +} + +.year-picker > .box > .text-field { + -fx-background-color: null; +} diff --git a/gemsfx/src/main/resources/com/dlsc/gemsfx/year-view.css b/gemsfx/src/main/resources/com/dlsc/gemsfx/year-view.css new file mode 100644 index 00000000..fef5b4d0 --- /dev/null +++ b/gemsfx/src/main/resources/com/dlsc/gemsfx/year-view.css @@ -0,0 +1,99 @@ +.year-view { + -fx-border-color: #e0e0e0; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.2), 12, 0.0, 0, 8); + -fx-pref-width: 240px; +} + +.year-view > .grid-pane { + -fx-background: -fx-control-inner-background-alt; + -fx-background-color: -fx-background; + -fx-hgap: 10px; + -fx-vgap: 10px; + -fx-padding: 10px; +} + +.year-view > .grid-pane > .year { + -fx-padding: 7px; + -fx-cursor: hand; +} + +.year-view > .grid-pane > .year.selected { + -fx-background: -fx-accent; + -fx-background-color: -fx-background; +} + +.year-view > .grid-pane > .year.current { + -fx-border-color: -fx-accent; + -fx-border-width: 1px; +} + +.year-view > .header { + -fx-padding: 0.588883em 0.5em 0.666667em 0.5em; /* 7 6 8 6 */ + -fx-background-color: derive(-fx-box-border,30%), linear-gradient(to bottom, derive(-fx-base,-3%), derive(-fx-base,5%) 50%, derive(-fx-base,-3%)); + -fx-background-insets: 0 0 0 0, 0 0 1 0; + -fx-alignment: center-left; + -fx-fill-height: false; +} + +.year-view > .header > .year-range-label { + -fx-alignment: center; +} + +.year-view > .header > .arrow-button { + -fx-background-color: -fx-outer-border, -fx-inner-border, -fx-body-color; + -fx-background-insets: 0, 1, 2; + -fx-color: transparent; + -fx-background-radius: 0; +} + +.year-view > .header > .arrow-button:focused { + -fx-background-color: -fx-focus-color, -fx-inner-border, -fx-body-color, -fx-faint-focus-color, -fx-body-color; + -fx-color: -fx-hover-base; + -fx-background-insets: -0.2, 1, 2, -1.4, 2.6; +} + +.year-view > .header > .arrow-button:hover { + -fx-color: -fx-hover-base; +} + +.year-view > .header > .arrow-button:armed { + -fx-color: -fx-pressed-base; +} + +.year-view > .header > .left-button { + -fx-padding: 0 0.333333em 0 0.25em; /* 0 4 0 3 */ +} + +.year-view > .header > .right-button { + -fx-padding: 0 0.25em 0 0.333333em; /* 0 3 0 4 */ +} + +.year-view > .header > .arrow-button > .left-arrow, +.year-view > .header > .arrow-button > .right-arrow { + -fx-background-color: -fx-mark-highlight-color, derive(-fx-base, -45%); + -fx-background-insets: 1 0 -1 0, 0; + -fx-padding: 0.333333em 0.166667em 0.333333em 0.166667em; /* 4 2 4 2 */ + -fx-effect: dropshadow(two-pass-box, -fx-shadow-highlight-color, 1, 0.0, 0, 1.4); +} + +.year-view > .header > .arrow-button:hover > .left-arrow, +.year-view > .header > .arrow-button:hover > .right-arrow { + -fx-background-color: -fx-mark-highlight-color, derive(-fx-base, -50%); +} + +.year-view > .header > .arrow-button:pressed > .left-arrow, +.year-view > .header > .arrow-button:pressed > .right-arrow { + -fx-background-color: -fx-mark-highlight-color, derive(-fx-base, -55%); +} + +.year-view > .header > .arrow-button > .left-arrow { + -fx-padding: 0.333333em 0.25em 0.333333em 0.166667em; /* 4 3 4 2 */ + -fx-shape: "M5.997,5.072L5.995,6.501l-2.998-4l2.998-4l0.002,1.43l-1.976,2.57L5.997,5.072z"; + -fx-scale-shape: true; +} + +.year-view > .header > .arrow-button > .right-arrow { + -fx-padding: 0.333333em 0.25em 0.333333em 0.166667em; /* 4 3 4 2 */ + -fx-shape: "M2.998-0.07L3-1.499l2.998,4L3,6.501l-0.002-1.43l1.976-2.57L2.998-0.07z"; + -fx-scale-shape: true; +}