blob: 33355ee660c856a21c22bf1c3072c60df14854ec [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.internal.gui;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Locale;
import javafx.beans.property.ReadOnlyProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TreeItem;
import javafx.scene.shape.Rectangle;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.stage.Window;
import javax.measure.Unit;
import javax.measure.Quantity;
import javax.measure.quantity.Length;
import org.apache.sis.internal.referencing.Formulas;
import org.apache.sis.measure.Quantities;
import org.apache.sis.measure.Units;
import org.apache.sis.util.Static;
import org.apache.sis.util.Localized;
import org.apache.sis.util.Workaround;
/**
* Miscellaneous utility methods.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.3
* @since 1.1
*/
public final class GUIUtilities extends Static {
/**
* Do not allow instantiation of this class.
*/
private GUIUtilities() {
}
/**
* Returns the locale of the JavaBean containing the given property, or {@code null} if unknown.
*
* @param property the property for which to get the locale, or {@code null}.
* @return the locale for the container of the given property, or {@code null}.
*/
public static Locale getLocale(final ObservableValue<?> property) {
if (property instanceof ReadOnlyProperty<?>) {
final Object bean = ((ReadOnlyProperty<?>) property).getBean();
if (bean instanceof Localized) {
return ((Localized) bean).getLocale();
}
}
return null;
}
/**
* Returns the window of the bean associated to the given property.
*
* @param property the property for which to get the window of the control, or {@code null}.
* @return the window, or {@code null} if unknown.
*/
public static Window getWindow(final ObservableValue<?> property) {
if (property instanceof ReadOnlyProperty<?>) {
final Object bean = ((ReadOnlyProperty<?>) property).getBean();
if (bean instanceof Node) {
final Scene scene = ((Node) bean).getScene();
if (scene != null) {
return scene.getWindow();
}
} else if (bean instanceof MenuItem) {
ContextMenu parent = ((MenuItem) bean).getParentPopup();
if (parent != null) {
for (;;) {
final Window owner = parent.getOwnerWindow();
if (!(owner instanceof ContextMenu)) return owner;
parent = (ContextMenu) owner;
}
}
}
}
return null;
}
/**
* Returns the window where is located the given JavaFX control.
*
* @param control the JavaFX control for which to get the window, or {@code null} if none.
* @return window containing the given control, or {@code null} if none.
*/
public static Window getWindow(final Node control) {
if (control != null) {
final Scene scene = control.getScene();
if (scene != null) {
return scene.getWindow();
}
}
return null;
}
/**
* Sets on the given pane a clip defined to the pane bounds. This method is invoked for pane having content
* that may be drawn outside the pane bounds (typically images). We use this method as a workaround for the
* fact that JavaFX pane does not apply clip by itself.
*
* @param pane the pane on which to set the clip.
*/
public static void setClipToBounds(final Pane pane) {
final Rectangle clip = new Rectangle();
clip.widthProperty() .bind(pane.widthProperty());
clip.heightProperty().bind(pane.heightProperty());
pane.setClip(clip);
}
/**
* Appends a path in a tree where all children lists are sorted. This method inserts all parents as needed.
* If the leaf at the given path already exists, then this method does nothing.
*
* @param <T> type of values in tree nodes.
* @param item root of the tree where to append a path.
* @param path path to the leaf to insert, together with all needed parents.
*/
@SafeVarargs
public static <T extends Comparable<? super T>> void appendPathSorted(TreeItem<T> item, final T... path) {
walk: for (final T search : path) {
final ObservableList<TreeItem<T>> children = item.getChildren();
int lo = 0, hi = children.size() - 1;
while (lo <= hi) {
final int m = (lo + hi) >>> 1; // Safe against overflow.
final TreeItem<T> child = children.get(m);
final int c = child.getValue().compareTo(search);
if (c < 0) {lo = m + 1; continue;}
if (c > 0) {hi = m - 1; continue;}
item = child; // Found existing item, continue down the path.
continue walk;
}
item = new TreeItem<>(search); // Item not found, insert it where it should be.
children.add(lo, item);
}
}
/**
* Removes a path in a tree where all children lists are sorted.
* This method prunes all parents that become empty as a result of this removal.
*
* @param <T> type of values in tree nodes.
* @param item root of the tree from where to remove a path.
* @param path path to the leaf to remove, together with its parents if they become empty.
*/
@SafeVarargs
public static <T extends Comparable<? super T>> void removePathSorted(TreeItem<T> item, final T... path) {
ObservableList<TreeItem<T>> removeFrom = null;
int removeIndex = 0;
walk: for (final T search : path) {
final ObservableList<TreeItem<T>> children = item.getChildren();
final int sm = children.size() - 1;
int hi = sm, lo = 0;
while (lo <= hi) {
final int m = (lo + hi) >>> 1;
final TreeItem<T> child = children.get(m);
final int c = child.getValue().compareTo(search);
if (c < 0) {lo = m + 1; continue;}
if (c > 0) {hi = m - 1; continue;}
if (sm != 0 || removeFrom == null) { // Found item. Remember to delete it or its parent.
removeFrom = children;
removeIndex = m;
}
item = child;
continue walk;
}
if (sm < 0) break; // Item not found. Stop the search.
else return;
}
if (removeFrom != null) {
removeFrom.remove(removeIndex);
}
}
/**
* Forces a {@link TreeItem} to update the {@code TreeView} when its value has been externally modified.
* This is a workaround for situations where the item's value is unchanged, but some state of the value
* has been modified.
*
* @param <T> type of values in the tree item.
* @param item the item for which to force an update.
*/
@Workaround(library = "JavaFX", version = "17")
public static <T> void forceCellUpdate(final TreeItem<T> item) {
final T value = item.getValue();
item.setValue(null);
item.setValue(value);
}
/**
* Sets the selected value or {@code target} to the same item than the selected item of {@code source}.
*
* @param <T> type of items.
* @param source the control from which to copy the selected item.
* @param target the control where to set the selected item.
*/
public static <T> void copySelection(final ChoiceBox<T> source, final ChoiceBox<T> target) {
target.getSelectionModel().select(source.getSelectionModel().getSelectedItem());
}
/**
* Copies all elements from the given source list to the specified target list,
* but with the application of insertion and removal operations only.
* This method is useful when the two lists should be similar.
* The intent is to causes as few change events as possible.
*
* @param <E> type of elements to copy.
* @param source the list of elements to copy in the target.
* @param target the list to modify with as few operations as possible.
* @return {@code true} if the target changed as a result of this method call.
*/
@SuppressWarnings("empty-statement")
public static <E> boolean copyAsDiff(final List<? extends E> source, final ObservableList<E> target) {
if (source.isEmpty()) {
final boolean empty = target.isEmpty();
target.clear();
return !empty;
}
if (target.isEmpty()) {
return target.setAll(source); // Return value is correct only if source is not empty.
}
final List<? extends E> lcs = longestCommonSubsequence(source, target);
/*
* Remove elements before to add new ones, because some listeners
* seem to be confused when a list contains duplicated elements
* (the removed elements may be inserted elsewhere).
*/
boolean modified = false;
int upper = target.size();
for (int i = lcs.size(); --i >= 0;) {
final E keep = lcs.get(i);
int lower = upper;
while (target.get(--lower) != keep); // A negative index here would be a bug in LCS computation.
if (lower + 1 < upper) {
target.remove(lower + 1, upper);
modified = true;
}
upper = lower;
}
if (upper != 0) {
target.remove(0, upper);
modified = true;
}
assert lcs.equals(target); // Because we removed all elements that were not present in LCS.
/*
* Now insert the new elements. We move forward for reducing the
* number of elements that `ObservableList` will have to shift.
* (We moved backward in the removal phase for the same reason).
*/
int lower = 0;
for (int i=0; i<target.size(); i++) {
final E skip = target.get(i);
upper = lower;
while (source.get(upper) != skip) upper++; // An index out of bounds would be a bug in LCS computation.
if (lower < upper) {
target.addAll(i, source.subList(lower, upper));
i += upper - lower;
modified = true;
}
lower = upper + 1;
}
upper = source.size();
if (lower < upper) {
target.addAll(source.subList(lower, upper));
modified = true;
}
assert source.equals(target);
return modified;
}
/**
* Returns the longest subsequence common to both specified sequences.
* This is known as <cite>longest common subsequence</cite> (LCS) problem.
* The LCS elements are not required to occupy consecutive positions within the original sequences.
*
* <h4>Example</h4>
* for the two following lists <var>x</var> and <var>y</var>,
* the longest common subsequence if given by <var>lcs</var> below:
*
* <pre class="text">
* x : 1 2 4 6 7 9
* y : 1 2 3 7 8
* lcs : 1 2 7</pre>
*
* This algorithm is useful for computing the differences between two sequences.
*
* @param <E> the type of elements in the sequences.
* @param x the first sequence for which to compute LCS.
* @param y the second sequence for which to compute LCS.
* @return longest common subsequence (LCS) between the two given sequences.
* May be one of the given lists.
*
* <a href="https://en.wikipedia.org/wiki/Longest_common_subsequence_problem">Longest common subsequence problem</a>
*/
static <E> List<? extends E> longestCommonSubsequence(List<? extends E> x, List<? extends E> y) {
final List<? extends E> ox = x; // The whole list, before sublisting that may be applied.
final List<? extends E> oy = y; // Idem.
/*
* This method can be optimized by excluding the common prefix and common suffix before
* to build the matrix. It can reduce a lot the matrix size and the number of iterations.
*/
int nx = x.size();
int ny = y.size();
final List<? extends E> prefix;
for (int i=0; ; i++) {
if (i >= nx) return x;
if (i >= ny) return y;
if (x.get(i) != y.get(i)) {
if (i == 0) {
prefix = List.of();
} else {
prefix = x.subList(0, i);
assert y.subList(0, i).equals(prefix);
x = x.subList(i, nx);
y = y.subList(i, ny);
nx -= i;
ny -= i;
}
break;
}
}
final List<? extends E> suffix;
for (int i=0; ; i++) {
final int sx = nx - i;
final int sy = ny - i;
if (sx == 0) return ox; // Concatenation of prefix + suffix = original list.
if (sy == 0) return oy;
if (x.get(sx - 1) != y.get(sy - 1)) {
if (i == 0) {
suffix = List.of();
} else {
suffix = x.subList(sx, nx);
assert y.subList(sy, ny).equals(suffix);
x = x.subList(0, sx);
y = y.subList(0, sy);
nx -= i;
ny -= i;
}
break;
}
}
/*
* We need a matrix of size (nx x ny) for storing LCS lengths for all (x[i], y[j]) pairs of elements.
* The matrix is augmented by one row and one column where all values in the first row and first column
* are zero. We could omit that row and that column for saving space, but it would complexify this code.
* For now we don't do that, but we may revisit in the future if this code is used for longer sequences.
*/
final int[][] lengths = new int[nx + 1][ny + 1];
for (int i=1; i<=nx; i++) {
final int im = i - 1;
final E xim = x.get(im);
for (int j=1; j<=ny; j++) {
final int jm = j - 1;
lengths[i][j] = (y.get(jm) == xim)
? Math.incrementExact(lengths[im][jm])
: Math.max(lengths[i][jm], lengths[im][j]);
}
}
/*
* The last cell contains the length of longest subsequence common to both lists.
* Following loop is the "traceback" procedure: starting from last cell, follows
* the direction where the length decrease.
*/
final List<E> lcs = new ArrayList<>(lengths[nx][ny] + prefix.size() + suffix.size());
while (nx > 0 && ny > 0) {
final int lg = lengths[nx][ny];
if (lengths[nx-1][ny] >= lg) {
nx--;
} else if (lengths[nx][--ny] < lg) {
final E ex = x.get(--nx);
assert ex == y.get(ny);
lcs.add(ex);
}
}
Collections.reverse(lcs);
lcs.addAll(0, prefix);
lcs.addAll( suffix);
return lcs;
}
/**
* Modify the quantity unit for showing a smaller value.
*
* @param quantity the quantity to modify, or {@code null}.
* @param m the quantity value in metres.
* @return the simplified quantity.
*/
public static Quantity<Length> shorter(final Quantity<Length> quantity, double m) {
final Unit<Length> unit;
if (m < 1) {
unit = Units.CENTIMETRE;
} else if (m < 1000) {
unit = Units.METRE;
} else {
unit = Units.KILOMETRE;
}
if (quantity != null && unit.equals(quantity.getUnit())) {
return quantity;
}
m = Units.METRE.getConverterTo(unit).convert(Math.max(m, Formulas.LINEAR_TOLERANCE));
return Quantities.create(m, unit);
}
/**
* Returns a color from a ARGB value packed in an integer.
*
* @param code the ARGB value.
* @return color for the given ARGB value.
*/
public static Color fromARGB(final int code) {
return Color.rgb(0xFF & (code >>> Byte.SIZE*2), // Red
0xFF & (code >>> Byte.SIZE), // Green
0xFF & (code)); // Blue
}
/**
* Returns a ARGB value packed in an integer.
*
* @param color color for which to get the ARGB value.
* @return ARGB value for the given color.
*/
public static int toARGB(final Color color) {
return (toByte(color.getOpacity()) << 3*Byte.SIZE)
| (toByte(color.getRed()) << 2*Byte.SIZE)
| (toByte(color.getGreen()) << Byte.SIZE)
| toByte(color.getBlue());
}
/**
* Converts a floating point value in the 0 … 1 range to an integer value in the 0 … 255 range.
*/
private static int toByte(final double value) {
return (int) Math.round(value * 255);
}
}