/* * Copyright (c) 2016 Jesper Öqvist <jesper@llbit.se> * * This file is part of Chunky. * * Chunky is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Chunky 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 General Public License for more details. * You should have received a copy of the GNU General Public License * along with Chunky. If not, see <http://www.gnu.org/licenses/>. */ package se.llbit.chunky.ui; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.DoubleProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; import javafx.geometry.Insets; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.Button; import javafx.scene.control.TextField; import javafx.scene.effect.DropShadow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.Pane; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.paint.CycleMethod; import javafx.scene.paint.LinearGradient; import javafx.scene.paint.Paint; import javafx.scene.paint.Stop; import javafx.scene.shape.Circle; import javafx.scene.shape.Rectangle; import se.llbit.math.Vector4; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.ResourceBundle; /** * Color palette for a simple JavaFX color picker. * * <p>The color palette shows a Hue gradient for picking the Hue value, * and a 2D HSV-gradient displaying a slice of the HSV color cube which * can be clicked to select the Saturation and Value components. * The color palette also has some swatches displaying neighbour colors * and previously selected colors. There is a large color swatch showing * the current color. * * <p>A cancel button is at the bottom right of the color palette, and the * current HTML color code is displayed in a text field at the bottom of the * palette. */ public class SimpleColorPalette extends Region implements Initializable { private static final Image gradientImage; static { List<Vector4> gradient = new ArrayList<>(); gradient.add(new Vector4(1, 0, 0, 0.00)); gradient.add(new Vector4(1, 1, 0, 1 / 6.0)); gradient.add(new Vector4(0, 1, 0, 2 / 6.0)); gradient.add(new Vector4(0, 1, 1, 3 / 6.0)); gradient.add(new Vector4(0, 0, 1, 4 / 6.0)); gradient.add(new Vector4(1, 0, 1, 5 / 6.0)); gradient.add(new Vector4(1, 0, 0, 1.00)); gradientImage = GradientEditor.drawGradient(522, 75, gradient); } private final SimpleColorPicker colorPicker; private Random random = new Random(); private @FXML VBox palette; private @FXML ImageView huePicker; private @FXML Canvas colorSample; private @FXML TextField webColorCode; private @FXML Button saveBtn; private @FXML Button cancelBtn; private @FXML StackPane satValueRect; private @FXML Pane huePickerOverlay; private @FXML Region sample0; private @FXML Region sample1; private @FXML Region sample2; private @FXML Region sample3; private @FXML Region sample4; private @FXML Region history0; private @FXML Region history1; private @FXML Region history2; private @FXML Region history3; private @FXML Region history4; private final DoubleProperty hue = new SimpleDoubleProperty(); private final DoubleProperty saturation = new SimpleDoubleProperty(); private final DoubleProperty value = new SimpleDoubleProperty(); private final Circle satValIndicator = new Circle(9); private final Rectangle hueIndicator = new Rectangle(20, 69); private Region[] sample; private Region[] history; /** * Set to true while the HTML color code listener is modifying the selected color. * When this is set to true it the updating of the HTML color code is disabled. */ private boolean editingHtmlCode = false; public SimpleColorPalette(SimpleColorPicker colorPicker) { this.colorPicker = colorPicker; try { FXMLLoader loader = new FXMLLoader(getClass().getResource("SimpleColorPalette.fxml")); loader.setController(this); getChildren().add(loader.load()); addEventFilter(KeyEvent.KEY_PRESSED, e -> { if (e.getCode() == KeyCode.ESCAPE) { e.consume(); colorPicker.revertToOriginalColor(); colorPicker.hide(); } }); } catch (IOException e) { throw new Error(e); } } @Override public void initialize(URL location, ResourceBundle resources) { sample = new Region[] { sample0, sample1, sample2, sample3, sample4 }; history = new Region[] { history0, history1, history2, history3, history4 }; // Handle color selection on click. colorSample.setOnMouseClicked(event -> { colorPicker.updateHistory(); colorPicker.hide(); }); webColorCode.textProperty().addListener((observable, oldValue, newValue) -> { try { editingHtmlCode = true; Color color = Color.web(newValue); hue.set(color.getHue() / 360); saturation.set(color.getSaturation()); value.set(color.getBrightness()); } catch (IllegalArgumentException e) { // Harmless exception - ignored. } finally { editingHtmlCode = false; } }); saveBtn.setOnAction(event -> { colorPicker.updateHistory(); colorPicker.hide(); }); saveBtn.setDefaultButton(true); cancelBtn.setOnAction(event -> { colorPicker.revertToOriginalColor(); colorPicker.hide(); }); satValueRect.setBackground( new Background(new BackgroundFill(Color.RED, CornerRadii.EMPTY, Insets.EMPTY))); satValueRect.backgroundProperty().bind(new ObjectBinding<Background>() { { bind(hue); } @Override protected Background computeValue() { return new Background( new BackgroundFill(Color.hsb(hue.get() * 360, 1.0, 1.0), CornerRadii.EMPTY, Insets.EMPTY)); } }); Pane saturationOverlay = new Pane(); saturationOverlay.setBackground(new Background(new BackgroundFill( new LinearGradient(0, 0, 1, 0, true, CycleMethod.NO_CYCLE, new Stop(0, Color.rgb(255, 255, 255, 1.0)), new Stop(1, Color.rgb(255, 255, 255, 0.0))), CornerRadii.EMPTY, Insets.EMPTY))); Pane valueOverlay = new Pane(); valueOverlay.setBackground(new Background(new BackgroundFill( new LinearGradient(0, 1, 0, 0, true, CycleMethod.NO_CYCLE, new Stop(0, Color.rgb(0, 0, 0, 1.0)), new Stop(1, Color.rgb(0, 0, 0, 0.0))), CornerRadii.EMPTY, Insets.EMPTY))); satValIndicator.layoutXProperty().bind(saturation.multiply(256)); satValIndicator.layoutYProperty().bind(Bindings.subtract(1, value).multiply(256)); satValIndicator.setStroke(Color.WHITE); satValIndicator.fillProperty().bind(new ObjectBinding<Paint>() { { bind(hue); bind(saturation); bind(value); } @Override protected Paint computeValue() { return Color.hsb(hue.get() * 360, saturation.get(), value.get()); } }); satValIndicator.setStrokeWidth(2); satValIndicator.setMouseTransparent(true); satValIndicator.setEffect(new DropShadow(5, Color.BLACK)); hueIndicator.setMouseTransparent(true); hueIndicator.setTranslateX(-10); hueIndicator.setTranslateY(3); hueIndicator.layoutXProperty().bind(hue.multiply(huePicker.fitWidthProperty())); hueIndicator.fillProperty().bind(new ObjectBinding<Paint>() { { bind(hue); } @Override protected Paint computeValue() { return Color.hsb(hue.get() * 360, 1.0, 1.0); } }); hueIndicator.setStroke(Color.WHITE); hueIndicator.setStrokeWidth(2); hueIndicator.setEffect(new DropShadow(5, Color.BLACK)); huePickerOverlay.getChildren().add(hueIndicator); huePickerOverlay.setClip(new Rectangle(522, 75)); valueOverlay.getChildren().add(satValIndicator); valueOverlay.setClip(new Rectangle(256, 256)); // Clip the indicator circle. satValueRect.getChildren().addAll(saturationOverlay, valueOverlay); setBackground(new Background( new BackgroundFill(Color.rgb(240, 240, 240), new CornerRadii(4.0), new Insets(0)))); DropShadow dropShadow = new DropShadow(); dropShadow.setColor(Color.color(0, 0, 0, 0.8)); dropShadow.setWidth(18); dropShadow.setHeight(18); setEffect(dropShadow); setHueGradient(); EventHandler<MouseEvent> hueMouseHandler = event -> hue.set(clamp(event.getX() / huePicker.getFitWidth())); huePickerOverlay.setOnMouseDragged(hueMouseHandler); huePickerOverlay.setOnMousePressed(hueMouseHandler); EventHandler<MouseEvent> mouseHandler = event -> { saturation.set(clamp(event.getX() / satValueRect.getWidth())); value.set(clamp(1 - event.getY() / satValueRect.getHeight())); }; valueOverlay.setOnMousePressed(mouseHandler); valueOverlay.setOnMouseDragged(mouseHandler); hue.addListener((observable, oldValue, newValue) -> updateCurrentColor(newValue.doubleValue(), saturation.get(), value.get())); saturation.addListener( (observable, oldValue, newValue) -> updateCurrentColor(hue.get(), newValue.doubleValue(), value.get())); value.addListener( (observable, oldValue, newValue) -> updateCurrentColor(hue.get(), saturation.get(), newValue.doubleValue())); EventHandler<MouseEvent> swatchClickHandler = event -> { if (event.getSource() instanceof Region) { Region swatch = (Region) event.getSource(); if (!swatch.getBackground().getFills().isEmpty()) { Color color = (Color) swatch.getBackground().getFills().get(0).getFill(); hue.set(color.getHue() / 360); saturation.set(color.getSaturation()); value.set(color.getBrightness()); } } }; for (Region region : history) { region.setOnMouseClicked(swatchClickHandler); } for (Region region : sample) { region.setOnMouseClicked(swatchClickHandler); } // Initialize history with random colors. for (Region swatch : history) { swatch.setBackground(new Background( new BackgroundFill(getRandomNearColor(colorPicker.getColor()), CornerRadii.EMPTY, Insets.EMPTY))); } } protected void setColor(Color color) { hue.set(color.getHue() / 360); saturation.set(color.getSaturation()); value.set(color.getBrightness()); } protected void addToHistory(Color color) { for (int i = history.length - 1; i >= 1; i -= 1) { history[i].setBackground(history[i - 1].getBackground()); } history[0].setBackground(new Background( new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY))); } private void setHueGradient() { huePicker.setImage(gradientImage); } private static double clamp(double value) { return (value < 0) ? 0 : (value > 1 ? 1 : value); } /** * Change the currently selected color and update UI state to match. */ private void updateCurrentColor(double hue, double saturation, double value) { updateCurrentColor(Color.hsb(hue * 360, saturation, value)); } /** * Change the currently selected color and update UI state to match. */ private void updateCurrentColor(Color newColor) { for (Region swatch : sample) { swatch.setBackground(new Background( new BackgroundFill(getRandomNearColor(newColor), CornerRadii.EMPTY, Insets.EMPTY))); } colorPicker.setColor(newColor); GraphicsContext gc = colorSample.getGraphicsContext2D(); gc.setFill(newColor); gc.fillRect(0, 0, colorSample.getWidth(), colorSample.getHeight()); if (!editingHtmlCode) { // TODO: make sure color values are rounded correctly. webColorCode.setText(String.format("#%02X%02X%02X", (int) (newColor.getRed() * 255 + 0.5), (int) (newColor.getGreen() * 255 + 0.5), (int) (newColor.getBlue() * 255 + 0.5))); } } private Color getRandomNearColor(Color color) { double hueMod = random.nextDouble() * .45; double satMod = random.nextDouble() * .75; double valMod = random.nextDouble() * .4; hueMod = 2 * (random.nextDouble() - .5) * 360 * hueMod * hueMod; satMod = 2 * (random.nextDouble() - .5) * satMod * satMod; valMod = 2 * (random.nextDouble() - .5) * valMod * valMod; double hue = color.getHue() + hueMod; double sat = Math.max(0, Math.min(1, color.getSaturation() + satMod)); double val = Math.max(0, Math.min(1, color.getBrightness() + valMod)); if (hue > 360) { hue -= 360; } else if (hue < 0) { hue += 360; } return Color.hsb(hue, sat, val); } }