Rewrite Breadcrumbs for more customization

This is breaking change which also significantly improves control API. It introduces divider factory and makes possible to use hyperlinks as control items.
This commit is contained in:
mkpaz 2022-10-05 16:34:17 +04:00
parent 71ea3aac8b
commit 2977aaca66
6 changed files with 314 additions and 330 deletions

@ -28,18 +28,13 @@
*/ */
package atlantafx.base.controls; package atlantafx.base.controls;
import javafx.beans.InvalidationListener;
import javafx.beans.property.*; import javafx.beans.property.*;
import javafx.event.Event; import javafx.event.Event;
import javafx.event.EventHandler; import javafx.event.EventHandler;
import javafx.event.EventType; import javafx.event.EventType;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Button; import javafx.scene.control.*;
import javafx.scene.control.Control; import javafx.scene.layout.Region;
import javafx.scene.control.Skin;
import javafx.scene.control.TreeItem;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.util.Callback; import javafx.util.Callback;
import java.util.UUID; import java.util.UUID;
@ -47,35 +42,48 @@ import java.util.UUID;
/** /**
* Represents a bread crumb bar. This control is useful to visualize and navigate * Represents a bread crumb bar. This control is useful to visualize and navigate
* a hierarchical path structure, such as file systems. * a hierarchical path structure, such as file systems.
* <p>
* A breadcrumbs consist of two types of elements: a button (default is a hyperlink)
* and a divider (default is for Label). You can customize both by providing the
* corresponding control factory.
*/ */
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class Breadcrumbs<T> extends Control { public class Breadcrumbs<T> extends Control {
private static final String STYLE_CLASS_FIRST = "first"; protected static final String DEFAULT_STYLE_CLASS = "breadcrumbs";
/** Represents an Event which is fired when a bread crumb was activated. */ protected final Callback<BreadCrumbItem<T>, ButtonBase> defaultCrumbNodeFactory =
public static class BreadCrumbActionEvent<TE> extends Event { item -> new Hyperlink(item.getStringValue());
protected final Callback<BreadCrumbItem<T>, ? extends Node> defaultDividerFactory =
item -> item != null && !item.isLast() ? new Label("/") : null;
/** Creates an empty bread crumb bar. */
public Breadcrumbs() {
this(null);
}
/** /**
* The event type that should be listened to by people interested in * Creates a bread crumb bar with the given BreadCrumbItem as the currently
* knowing when the {@link Breadcrumbs#selectedCrumbProperty() selected crumb} * selected crumb.
* has changed.
*/ */
public static final EventType<BreadCrumbActionEvent<?>> CRUMB_ACTION public Breadcrumbs(BreadCrumbItem<T> selectedCrumb) {
= new EventType<>("CRUMB_ACTION" + UUID.randomUUID()); getStyleClass().add(DEFAULT_STYLE_CLASS);
private final TreeItem<TE> selectedCrumb; // breadcrumbs should be the size of its content
setPrefWidth(Region.USE_COMPUTED_SIZE);
setMaxWidth(Region.USE_PREF_SIZE);
setPrefHeight(Region.USE_COMPUTED_SIZE);
setMaxHeight(Region.USE_PREF_SIZE);
/** Creates a new event that can subsequently be fired. */ setSelectedCrumb(selectedCrumb);
public BreadCrumbActionEvent(TreeItem<TE> selectedCrumb) { setCrumbFactory(defaultCrumbNodeFactory);
super(CRUMB_ACTION); setDividerFactory(defaultDividerFactory);
this.selectedCrumb = selectedCrumb;
} }
/** Returns the crumb which was the action target. */ /** {@inheritDoc} */
public TreeItem<TE> getSelectedCrumb() { @Override
return selectedCrumb; protected Skin<?> createDefaultSkin() {
} return new BreadcrumbsSkin<>(this);
} }
/** /**
@ -83,10 +91,10 @@ public class Breadcrumbs<T> extends Control {
* as selectedCrumb node to be shown * as selectedCrumb node to be shown
*/ */
@SafeVarargs @SafeVarargs
public static <T> TreeItem<T> buildTreeModel(T... crumbs) { public static <T> BreadCrumbItem<T> buildTreeModel(T... crumbs) {
TreeItem<T> subRoot = null; BreadCrumbItem<T> subRoot = null;
for (T crumb : crumbs) { for (T crumb : crumbs) {
TreeItem<T> currentNode = new TreeItem<>(crumb); BreadCrumbItem<T> currentNode = new BreadCrumbItem<>(crumb);
if (subRoot != null) { if (subRoot != null) {
subRoot.getChildren().add(currentNode); subRoot.getChildren().add(currentNode);
} }
@ -95,24 +103,9 @@ public class Breadcrumbs<T> extends Control {
return subRoot; return subRoot;
} }
/** Default crumb node factory. This factory is used when no custom factory is specified by the user. */ ///////////////////////////////////////////////////////////////////////////
private final Callback<TreeItem<T>, Button> defaultCrumbNodeFactory = // Properties //
crumb -> new BreadCrumbButton(crumb.getValue() != null ? crumb.getValue().toString() : ""); ///////////////////////////////////////////////////////////////////////////
/** Creates an empty bread crumb bar. */
public Breadcrumbs() {
this(null);
}
/**
* Creates a bread crumb bar with the given TreeItem as the currently
* selected crumb.
*/
public Breadcrumbs(TreeItem<T> selectedCrumb) {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setSelectedCrumb(selectedCrumb);
setCrumbFactory(defaultCrumbNodeFactory);
}
/** /**
* Represents the bottom-most path node (the node on the most-right side in * Represents the bottom-most path node (the node on the most-right side in
@ -125,26 +118,25 @@ public class Breadcrumbs<T> extends Control {
* <p> * <p>
* To show the above bread crumb bar, you have to set the [file.txt] tree-node as selected crumb. * To show the above bread crumb bar, you have to set the [file.txt] tree-node as selected crumb.
*/ */
public final ObjectProperty<TreeItem<T>> selectedCrumbProperty() { public final ObjectProperty<BreadCrumbItem<T>> selectedCrumbProperty() {
return selectedCrumb; return selectedCrumb;
} }
private final ObjectProperty<TreeItem<T>> selectedCrumb = protected final ObjectProperty<BreadCrumbItem<T>> selectedCrumb =
new SimpleObjectProperty<>(this, "selectedCrumb"); new SimpleObjectProperty<>(this, "selectedCrumb");
/** Get the current target path. */ public final BreadCrumbItem<T> getSelectedCrumb() {
public final TreeItem<T> getSelectedCrumb() {
return selectedCrumb.get(); return selectedCrumb.get();
} }
/** Select one node in the BreadCrumbBar for being the bottom-most path node. */ public final void setSelectedCrumb(BreadCrumbItem<T> selectedCrumb) {
public final void setSelectedCrumb(TreeItem<T> selectedCrumb) {
this.selectedCrumb.set(selectedCrumb); this.selectedCrumb.set(selectedCrumb);
} }
/** /**
* Enable or disable auto navigation (default is enabled). * Enables or disables auto navigation (default is enabled).
* If auto navigation is enabled, it will automatically navigate to the crumb which was clicked by the user. * If auto navigation is enabled, it will automatically navigate to the crumb which was
* clicked by the user.
* *
* @return a {@link BooleanProperty} * @return a {@link BooleanProperty}
*/ */
@ -152,59 +144,77 @@ public class Breadcrumbs<T> extends Control {
return autoNavigation; return autoNavigation;
} }
private final BooleanProperty autoNavigation = protected final BooleanProperty autoNavigation =
new SimpleBooleanProperty(this, "autoNavigationEnabled", true); new SimpleBooleanProperty(this, "autoNavigationEnabled", true);
/**
* Return whether auto-navigation is enabled.
*
* @return whether auto-navigation is enabled.
*/
public final boolean isAutoNavigationEnabled() { public final boolean isAutoNavigationEnabled() {
return autoNavigation.get(); return autoNavigation.get();
} }
/**
* Enable or disable auto navigation (default is enabled).
* If auto navigation is enabled, it will automatically navigate to the crumb which was clicked by the user.
*/
public final void setAutoNavigationEnabled(boolean enabled) { public final void setAutoNavigationEnabled(boolean enabled) {
autoNavigation.set(enabled); autoNavigation.set(enabled);
} }
/** /**
* Return an ObjectProperty of the CrumbFactory. * Crumb factory is used to create custom bread crumb instances.
* * <code>null</code> is not allowed and will result in a fallback to the default factory.
* @return an ObjectProperty of the CrumbFactory. * <p>
* <code>BreadCrumbItem<T></code> specifies the tree item for creating bread crumb. Use
* {@link BreadCrumbItem#isFirst()} and {@link BreadCrumbItem#isLast()} to create bread crumb
* depending on item position.
* <p>
* <code>ButtonBase</code> stands for resulting bread crumb node. It CAN NOT be <code>null</code>.
*/ */
public final ObjectProperty<Callback<TreeItem<T>, Button>> crumbFactoryProperty() { public final ObjectProperty<Callback<BreadCrumbItem<T>, ButtonBase>> crumbFactoryProperty() {
return crumbFactory; return crumbFactory;
} }
private final ObjectProperty<Callback<TreeItem<T>, Button>> crumbFactory = protected final ObjectProperty<Callback<BreadCrumbItem<T>, ButtonBase>> crumbFactory =
new SimpleObjectProperty<>(this, "crumbFactory"); new SimpleObjectProperty<>(this, "crumbFactory");
/** public final void setCrumbFactory(Callback<BreadCrumbItem<T>, ButtonBase> value) {
* Sets the crumb factory to create (custom) {@link BreadCrumbButton} instances.
* <code>null</code> is not allowed and will result in a fallback to the default factory.
*/
public final void setCrumbFactory(Callback<TreeItem<T>, Button> value) {
if (value == null) { if (value == null) {
value = defaultCrumbNodeFactory; value = defaultCrumbNodeFactory;
} }
crumbFactoryProperty().set(value); crumbFactoryProperty().set(value);
} }
/** public final Callback<BreadCrumbItem<T>, ButtonBase> getCrumbFactory() {
* Returns the cell factory that will be used to create {@link BreadCrumbButton}
* instances
*/
public final Callback<TreeItem<T>, Button> getCrumbFactory() {
return crumbFactory.get(); return crumbFactory.get();
} }
/** /**
* @return an ObjectProperty representing the crumbAction EventHandler being used. * Divider factory is used to create custom divider instances.
* <code>null</code> is not allowed and will result in a fallback to the default factory.
* <p>
* <code>BreadCrumbItem<T></code> specifies the preceding tree item. It can be null, because this way
* you can insert divider before the first bread crumb, which can be used e.g. for creating a Unix path.
* Use {@link BreadCrumbItem#isFirst()} and {@link BreadCrumbItem#isLast()} to create divider
* depending on item position.
* <p>
* <code>? extends Node</code> stands for resulting divider node. It CAN be <code>null</code>, which
* means there will be no divider inserted after the specified bread crumb.
*/
public final ObjectProperty<Callback<BreadCrumbItem<T>, ? extends Node>> dividerFactoryProperty() {
return dividerFactory;
}
protected final ObjectProperty<Callback<BreadCrumbItem<T>, ? extends Node>> dividerFactory =
new SimpleObjectProperty<>(this, "dividerFactory");
public final void setDividerFactory(Callback<BreadCrumbItem<T>, ? extends Node> value) {
if (value == null) {
value = defaultDividerFactory;
}
dividerFactoryProperty().set(value);
}
public final Callback<BreadCrumbItem<T>, ? extends Node> getDividerFactory() {
return dividerFactory.get();
}
/**
* The EventHandler is called when a user selects a bread crumb.
*/ */
public final ObjectProperty<EventHandler<BreadCrumbActionEvent<T>>> onCrumbActionProperty() { public final ObjectProperty<EventHandler<BreadCrumbActionEvent<T>>> onCrumbActionProperty() {
return onCrumbAction; return onCrumbAction;
@ -215,16 +225,11 @@ public class Breadcrumbs<T> extends Control {
onCrumbActionProperty().set(value); onCrumbActionProperty().set(value);
} }
/**
* Return the EventHandler currently used when a user selects a crumb.
*
* @return the EventHandler currently used when a user selects a crumb.
*/
public final EventHandler<BreadCrumbActionEvent<T>> getOnCrumbAction() { public final EventHandler<BreadCrumbActionEvent<T>> getOnCrumbAction() {
return onCrumbActionProperty().get(); return onCrumbActionProperty().get();
} }
private final ObjectProperty<EventHandler<BreadCrumbActionEvent<T>>> onCrumbAction = new ObjectPropertyBase<>() { protected final ObjectProperty<EventHandler<BreadCrumbActionEvent<T>>> onCrumbAction = new ObjectPropertyBase<>() {
@SuppressWarnings({ "unchecked", "rawtypes" }) @SuppressWarnings({ "unchecked", "rawtypes" })
@Override @Override
@ -243,117 +248,60 @@ public class Breadcrumbs<T> extends Control {
} }
}; };
private static final String DEFAULT_STYLE_CLASS = "bread-crumb-bar"; ///////////////////////////////////////////////////////////////////////////
/** {@inheritDoc} */ public static class BreadCrumbItem<T> extends TreeItem<T> {
@Override
protected Skin<?> createDefaultSkin() { // setters must not be exposed to user
return new BreadcrumbsSkin<>(this); private boolean first;
private boolean last;
public BreadCrumbItem(T value) {
super(value);
} }
@SuppressWarnings("FieldCanBeLocal") public boolean isFirst() {
public static class BreadCrumbButton extends Button { return first;
}
private final ObjectProperty<Boolean> first = new SimpleObjectProperty<>(this, STYLE_CLASS_FIRST); void setFirst(boolean first) {
this.first = first;
}
private final double arrowWidth = 5; public boolean isLast() {
private final double arrowHeight = 20; return last;
}
void setLast(boolean last) {
this.last = last;
}
String getStringValue() {
return getValue() != null ? getValue().toString() : "";
}
}
/** Represents an Event which is fired when a bread crumb was activated. */
public static class BreadCrumbActionEvent<TE> extends Event {
/** /**
* Create a BreadCrumbButton * The event type that should be listened to by people interested in
* * knowing when the {@link Breadcrumbs#selectedCrumbProperty() selected crumb}
* @param text Buttons text * has changed.
*/ */
public BreadCrumbButton(String text) { public static final EventType<BreadCrumbActionEvent<?>> CRUMB_ACTION
this(text, null); = new EventType<>("CRUMB_ACTION" + UUID.randomUUID());
private final BreadCrumbItem<TE> selectedCrumb;
/** Creates a new event that can subsequently be fired. */
public BreadCrumbActionEvent(BreadCrumbItem<TE> selectedCrumb) {
super(CRUMB_ACTION);
this.selectedCrumb = selectedCrumb;
} }
/** public BreadCrumbItem<TE> getSelectedCrumb() {
* Create a BreadCrumbButton return selectedCrumb;
*
* @param text Buttons text
* @param gfx Gfx of the Button
*/
public BreadCrumbButton(String text, Node gfx) {
super(text, gfx);
first.set(false);
getStyleClass().addListener((InvalidationListener) obs -> updateShape());
updateShape();
}
private void updateShape() {
this.setShape(createButtonShape());
}
/** Returns the crumb arrow width. */
public double getArrowWidth() {
return arrowWidth;
}
/** Creates an arrow path. */
private Path createButtonShape() {
// build the following shape (or home without left arrow)
// --------
// \ \
// / /
// --------
Path path = new Path();
// begin in the upper left corner
MoveTo e1 = new MoveTo(0, 0);
path.getElements().add(e1);
// draw a horizontal line that defines the width of the shape
HLineTo e2 = new HLineTo();
// bind the width of the shape to the width of the button
e2.xProperty().bind(this.widthProperty().subtract(arrowWidth));
path.getElements().add(e2);
// draw upper part of right arrow
LineTo e3 = new LineTo();
// the x endpoint of this line depends on the x property of line e2
e3.xProperty().bind(e2.xProperty().add(arrowWidth));
e3.setY(arrowHeight / 2.0);
path.getElements().add(e3);
// draw lower part of right arrow
LineTo e4 = new LineTo();
// the x endpoint of this line depends on the x property of line e2
e4.xProperty().bind(e2.xProperty());
e4.setY(arrowHeight);
path.getElements().add(e4);
// draw lower horizontal line
HLineTo e5 = new HLineTo(0);
path.getElements().add(e5);
if (!getStyleClass().contains(STYLE_CLASS_FIRST)) {
// draw lower part of left arrow
// we simply can omit it for the first Button
LineTo e6 = new LineTo(arrowWidth, arrowHeight / 2.0);
path.getElements().add(e6);
} else {
// draw an arc for the first bread crumb
ArcTo arcTo = new ArcTo();
arcTo.setSweepFlag(true);
arcTo.setX(0);
arcTo.setY(0);
arcTo.setRadiusX(15.0f);
arcTo.setRadiusY(15.0f);
path.getElements().add(arcTo);
}
// close path
ClosePath e7 = new ClosePath();
path.getElements().add(e7);
// this is a dummy color to fill the shape, it won't be visible
path.setFill(Color.BLACK);
return path;
} }
} }
} }

@ -28,15 +28,14 @@
*/ */
package atlantafx.base.controls; package atlantafx.base.controls;
import javafx.beans.value.ChangeListener; import atlantafx.base.controls.Breadcrumbs.BreadCrumbItem;
import javafx.css.PseudoClass;
import javafx.event.Event; import javafx.event.Event;
import javafx.event.EventHandler; import javafx.event.EventHandler;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Button; import javafx.scene.control.ButtonBase;
import javafx.scene.control.SkinBase; import javafx.scene.control.SkinBase;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeItem.TreeModificationEvent; import javafx.scene.control.TreeItem.TreeModificationEvent;
import javafx.util.Callback;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -44,89 +43,90 @@ import java.util.List;
public class BreadcrumbsSkin<T> extends SkinBase<Breadcrumbs<T>> { public class BreadcrumbsSkin<T> extends SkinBase<Breadcrumbs<T>> {
private static final String STYLE_CLASS_FIRST = "first"; protected static final PseudoClass FIRST = PseudoClass.getPseudoClass("first");
private static final String STYLE_CLASS_LAST = "last"; protected static final PseudoClass LAST = PseudoClass.getPseudoClass("last");
protected final EventHandler<TreeModificationEvent<Object>> treeChildrenModifiedHandler = e -> updateBreadCrumbs();
public BreadcrumbsSkin(final Breadcrumbs<T> control) { public BreadcrumbsSkin(final Breadcrumbs<T> control) {
super(control); super(control);
control.selectedCrumbProperty().addListener(selectedPathChangeListener);
control.selectedCrumbProperty().addListener((obs, old, val) -> updateSelectedPath(old, val));
updateSelectedPath(getSkinnable().selectedCrumbProperty().get(), null); updateSelectedPath(getSkinnable().selectedCrumbProperty().get(), null);
} }
@SuppressWarnings("FieldCanBeLocal") @Override
private final ChangeListener<TreeItem<T>> selectedPathChangeListener = protected void layoutChildren(double x, double y, double width, double height) {
(obs, oldItem, newItem) -> updateSelectedPath(newItem, oldItem); double controlHeight = getSkinnable().getHeight();
double nodeX = x, nodeY;
private void updateSelectedPath(TreeItem<T> newTarget, TreeItem<T> oldTarget) { for (int i = 0; i < getChildren().size(); i++) {
if (oldTarget != null) { Node node = getChildren().get(i);
// remove old listener
oldTarget.removeEventHandler(TreeItem.childrenModificationEvent(), treeChildrenModifiedHandler);
}
if (newTarget != null) {
// add new listener
newTarget.addEventHandler(TreeItem.childrenModificationEvent(), treeChildrenModifiedHandler);
}
updateBreadCrumbs();
}
private final EventHandler<TreeModificationEvent<Object>> treeChildrenModifiedHandler = double nodeWidth = snapSizeX(node.prefWidth(height));
args -> updateBreadCrumbs(); double nodeHeight = snapSizeY(node.prefHeight(-1));
private void updateBreadCrumbs() { // center node within the breadcrumbs
final Breadcrumbs<T> buttonBar = getSkinnable(); nodeY = nodeHeight < controlHeight ? (controlHeight - nodeHeight) / 2 : y;
final TreeItem<T> pathTarget = buttonBar.getSelectedCrumb();
final Callback<TreeItem<T>, Button> factory = buttonBar.getCrumbFactory();
getChildren().clear(); node.resizeRelocate(nodeX, nodeY, nodeWidth, nodeHeight);
nodeX += nodeWidth;
if (pathTarget != null) {
List<TreeItem<T>> crumbs = constructFlatPath(pathTarget);
for (int i = 0; i < crumbs.size(); i++) {
Button crumb = createCrumb(factory, crumbs.get(i));
crumb.setMnemonicParsing(false);
boolean first = crumbs.size() > 1 && i == 0;
boolean last = crumbs.size() > 1 && i == crumbs.size() - 1;
if (first) {
crumb.getStyleClass().remove(STYLE_CLASS_LAST);
addStyleClass(crumb, STYLE_CLASS_FIRST);
} else if (last) {
crumb.getStyleClass().remove(STYLE_CLASS_FIRST);
addStyleClass(crumb, STYLE_CLASS_LAST);
} else {
crumb.getStyleClass().removeAll(STYLE_CLASS_FIRST, STYLE_CLASS_LAST);
}
getChildren().add(crumb);
}
}
}
private void addStyleClass(Node node, String styleClass) {
if (!node.getStyleClass().contains(styleClass)) {
node.getStyleClass().add(styleClass);
} }
} }
@Override @Override
protected void layoutChildren(double x, double y, double w, double h) { protected double computeMinWidth(double height, double topInset, double rightInset,
for (int i = 0; i < getChildren().size(); i++) { double bottomInset, double leftInset) {
Node n = getChildren().get(i); double width = 0;
for (Node node : getChildren()) {
double nw = snapSizeX(n.prefWidth(h)); if (!node.isManaged()) { continue; }
double nh = snapSizeY(n.prefHeight(-1)); width += snapSizeX(node.prefWidth(height));
if (i > 0) {
// we have to position the bread crumbs slightly overlapping
double ins = n instanceof Breadcrumbs.BreadCrumbButton d ? d.getArrowWidth() : 0;
x = snapPositionX(x - ins);
} }
n.resize(nw, nh); return width + rightInset + leftInset;
n.relocate(x, y); }
x += nw;
protected void updateSelectedPath(BreadCrumbItem<T> old, BreadCrumbItem<T> val) {
if (old != null) {
old.removeEventHandler(BreadCrumbItem.childrenModificationEvent(), treeChildrenModifiedHandler);
}
if (val != null) {
val.addEventHandler(BreadCrumbItem.childrenModificationEvent(), treeChildrenModifiedHandler);
}
updateBreadCrumbs();
}
protected void updateBreadCrumbs() {
getChildren().clear();
BreadCrumbItem<T> selectedTreeItem = getSkinnable().getSelectedCrumb();
Node divider;
if (selectedTreeItem != null) {
// optionally insert divider before the first node
divider = createDivider(null);
if (divider != null) {
divider.pseudoClassStateChanged(FIRST, true);
divider.pseudoClassStateChanged(LAST, false);
getChildren().add(divider);
}
List<BreadCrumbItem<T>> crumbs = constructFlatPath(selectedTreeItem);
for (BreadCrumbItem<T> treeItem : crumbs) {
ButtonBase crumb = createCrumb(treeItem);
crumb.pseudoClassStateChanged(FIRST, treeItem.isFirst());
crumb.pseudoClassStateChanged(LAST, treeItem.isLast());
getChildren().add(crumb);
// for the sake of flexibility, it's user responsibility to decide
// whether insert divider after the last node or not
divider = createDivider(treeItem);
if (divider != null) {
if (treeItem.isLast()) {
divider.pseudoClassStateChanged(FIRST, false);
divider.pseudoClassStateChanged(LAST, true);
}
getChildren().add(divider);
}
}
} }
} }
@ -135,39 +135,59 @@ public class BreadcrumbsSkin<T> extends SkinBase<Breadcrumbs<T>> {
* *
* @param bottomMost The crumb node at the end of the path * @param bottomMost The crumb node at the end of the path
*/ */
private List<TreeItem<T>> constructFlatPath(TreeItem<T> bottomMost) { protected List<BreadCrumbItem<T>> constructFlatPath(BreadCrumbItem<T> bottomMost) {
List<TreeItem<T>> path = new ArrayList<>(); List<BreadCrumbItem<T>> path = new ArrayList<>();
TreeItem<T> current = bottomMost; BreadCrumbItem<T> current = bottomMost;
do { do {
path.add(current); path.add(current);
current = current.getParent(); current.setFirst(false);
current.setLast(false);
current = (BreadCrumbItem<T>) current.getParent();
} while (current != null); } while (current != null);
Collections.reverse(path); Collections.reverse(path);
// if the path consists of a single item it considered as first, but not last
if (path.size() > 0) { path.get(0).setFirst(true); }
if (path.size() > 1) { path.get(path.size() - 1).setLast(true); }
return path; return path;
} }
private Button createCrumb( protected ButtonBase createCrumb(BreadCrumbItem<T> treeItem) {
final Callback<TreeItem<T>, Button> factory, ButtonBase crumb = getSkinnable().getCrumbFactory().call(treeItem);
final TreeItem<T> selectedCrumb) { crumb.setMnemonicParsing(false);
Button crumb = factory.call(selectedCrumb); if (!crumb.getStyleClass().contains("crumb")) {
crumb.getStyleClass().add("crumb");
crumb.getStyleClass().add("crumb"); //$NON-NLS-1$ }
// listen to the action event of each bread crumb // listen to the action event of each bread crumb
crumb.setOnAction(ae -> onBreadCrumbAction(selectedCrumb)); crumb.setOnAction(e -> onBreadCrumbAction(treeItem));
return crumb; return crumb;
} }
protected Node createDivider(BreadCrumbItem<T> treeItem) {
Node divider = getSkinnable().getDividerFactory().call(treeItem);
if (divider == null) {
return null;
}
if (!divider.getStyleClass().contains("divider")) {
divider.getStyleClass().add("divider");
}
return divider;
}
/** /**
* Occurs when a bread crumb gets the action event * Occurs when a bread crumb gets the action event
* *
* @param crumbModel The crumb which received the action event * @param crumbModel The crumb which received the action event
*/ */
protected void onBreadCrumbAction(final TreeItem<T> crumbModel) { protected void onBreadCrumbAction(BreadCrumbItem<T> crumbModel) {
final Breadcrumbs<T> breadCrumbBar = getSkinnable(); final Breadcrumbs<T> breadCrumbBar = getSkinnable();
// fire the composite event in the breadCrumbBar // fire the composite event in the breadCrumbBar

@ -2,69 +2,83 @@
package atlantafx.sampler.page.components; package atlantafx.sampler.page.components;
import atlantafx.base.controls.Breadcrumbs; import atlantafx.base.controls.Breadcrumbs;
import atlantafx.base.controls.Breadcrumbs.BreadCrumbItem;
import atlantafx.base.theme.Styles; import atlantafx.base.theme.Styles;
import atlantafx.sampler.page.AbstractPage; import atlantafx.sampler.page.AbstractPage;
import atlantafx.sampler.page.Page; import atlantafx.sampler.page.Page;
import atlantafx.sampler.page.SampleBlock; import atlantafx.sampler.page.SampleBlock;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.TreeItem; import javafx.scene.control.ButtonBase;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.util.Callback; import javafx.util.Callback;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
public class BreadcrumbsPage extends AbstractPage { public class BreadcrumbsPage extends AbstractPage {
public static final String NAME = "Breadcrumbs"; public static final String NAME = "Breadcrumbs";
private static final int CRUMB_COUNT = 5;
@Override @Override
public String getName() { return NAME; } public String getName() { return NAME; }
public BreadcrumbsPage() { public BreadcrumbsPage() {
super(); super();
setUserContent(new VBox(Page.PAGE_VGAP, setUserContent(new VBox(
defaultSample(), Page.PAGE_VGAP,
customCrumbSample() basicSample(),
customCrumbSample(),
customDividerSample()
)); ));
} }
private SampleBlock defaultSample() { private SampleBlock basicSample() {
return new SampleBlock("Basic", createBreadcrumbs(null)); return new SampleBlock("Basic", createBreadcrumbs(null, null));
} }
private SampleBlock customCrumbSample() { private SampleBlock customCrumbSample() {
Callback<TreeItem<String>, Button> crumbFactory = crumb -> { Callback<BreadCrumbItem<String>, ButtonBase> crumbFactory = crumb -> {
var btn = new Button(crumb.getValue()); var btn = new Button(crumb.getValue(), new FontIcon(randomIcon()));
btn.getStyleClass().add(Styles.FLAT); btn.getStyleClass().add(Styles.FLAT);
btn.setFocusTraversable(false); btn.setFocusTraversable(false);
if (!crumb.getChildren().isEmpty()) {
btn.setGraphic(new FontIcon(Feather.CHEVRON_RIGHT));
}
return btn; return btn;
}; };
return new SampleBlock("Flat", createBreadcrumbs(crumbFactory)); return new SampleBlock("Flat Button", createBreadcrumbs(crumbFactory, null));
} }
private HBox createBreadcrumbs(Callback<TreeItem<String>, Button> crumbFactory) { private SampleBlock customDividerSample() {
int count = 5; Callback<BreadCrumbItem<String>, ? extends Node> dividerFactory = item -> {
TreeItem<String> model = Breadcrumbs.buildTreeModel( if (item == null) { return new Label("", new FontIcon(Material2AL.HOME)); }
generate(() -> FAKER.science().element(), count).toArray(String[]::new) return !item.isLast() ? new Label("", new FontIcon(Material2AL.CHEVRON_RIGHT)) : null;
};
return new SampleBlock("Custom Divider", createBreadcrumbs(null, dividerFactory));
}
private HBox createBreadcrumbs(Callback<BreadCrumbItem<String>, ButtonBase> crumbFactory,
Callback<BreadCrumbItem<String>, ? extends Node> dividerFactory) {
BreadCrumbItem<String> model = Breadcrumbs.buildTreeModel(
generate(() -> FAKER.science().element(), CRUMB_COUNT).toArray(String[]::new)
); );
var nextBtn = new Button("Next"); var nextBtn = new Button("Next");
nextBtn.getStyleClass().add(Styles.ACCENT); nextBtn.getStyleClass().addAll(Styles.ACCENT);
var breadcrumbs = new Breadcrumbs<>(model); var breadcrumbs = new Breadcrumbs<>(model);
breadcrumbs.setSelectedCrumb(getAncestor(model, count / 2)); breadcrumbs.setSelectedCrumb(getAncestor(model, CRUMB_COUNT / 2));
if (crumbFactory != null) { breadcrumbs.setCrumbFactory(crumbFactory); } if (crumbFactory != null) { breadcrumbs.setCrumbFactory(crumbFactory); }
if (dividerFactory != null) { breadcrumbs.setDividerFactory(dividerFactory); }
nextBtn.setOnAction(e -> { nextBtn.setOnAction(e -> {
TreeItem<String> selected = breadcrumbs.getSelectedCrumb(); BreadCrumbItem<String> selected = breadcrumbs.getSelectedCrumb();
if (selected.getChildren().size() > 0) { if (selected.getChildren().size() > 0) {
breadcrumbs.setSelectedCrumb(selected.getChildren().get(0)); breadcrumbs.setSelectedCrumb((BreadCrumbItem<String>) selected.getChildren().get(0));
} }
}); });
@ -80,13 +94,19 @@ public class BreadcrumbsPage extends AbstractPage {
return box; return box;
} }
private <T> TreeItem<T> getAncestor(TreeItem<T> node, int height) { private <T> BreadCrumbItem<T> getAncestor(BreadCrumbItem<T> node, int height) {
var counter = height; var counter = height;
var current = node; var current = node;
while (counter > 0 && current.getParent() != null) { while (counter > 0 && current.getParent() != null) {
current = current.getParent(); current = (BreadCrumbItem<T>) current.getParent();
counter--; counter--;
} }
return current; return current;
} }
@Override
protected double computePrefHeight(double width) {
System.out.println("pref height" + super.computePrefHeight(width));
return super.computePrefHeight(width);
}
} }

@ -2,21 +2,23 @@
package atlantafx.sampler.page.showcase.filemanager; package atlantafx.sampler.page.showcase.filemanager;
import atlantafx.base.controls.Breadcrumbs; import atlantafx.base.controls.Breadcrumbs;
import atlantafx.base.controls.Breadcrumbs.BreadCrumbItem;
import atlantafx.base.controls.Spacer; import atlantafx.base.controls.Spacer;
import atlantafx.base.theme.Tweaks; import atlantafx.base.theme.Tweaks;
import atlantafx.sampler.page.showcase.ShowcasePage; import atlantafx.sampler.page.showcase.ShowcasePage;
import atlantafx.sampler.util.Containers; import atlantafx.sampler.util.Containers;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.text.Text; import javafx.scene.text.Text;
import javafx.util.Callback; import javafx.util.Callback;
import org.kordamp.ikonli.feather.Feather; import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
import org.kordamp.ikonli.material2.Material2MZ; import org.kordamp.ikonli.material2.Material2MZ;
import java.net.URI; import java.net.URI;
@ -156,24 +158,23 @@ public class FileManagerPage extends ShowcasePage {
} }
private Breadcrumbs<Path> breadCrumbs() { private Breadcrumbs<Path> breadCrumbs() {
Callback<TreeItem<Path>, Button> crumbFactory = crumb -> { Callback<BreadCrumbItem<Path>, ButtonBase> crumbFactory = crumb -> {
var btn = new Button(crumb.getValue().getFileName().toString()); var btn = new Button(crumb.getValue().getFileName().toString());
btn.getStyleClass().add(FLAT); btn.getStyleClass().add(FLAT);
btn.setFocusTraversable(false); btn.setFocusTraversable(false);
if (!crumb.getChildren().isEmpty()) {
btn.setGraphic(new FontIcon(Feather.CHEVRON_RIGHT));
}
return btn; return btn;
}; };
Callback<BreadCrumbItem<Path>, ? extends Node> dividerFactory = item ->
item != null && !item.isLast() ? new Label("", new FontIcon(Material2AL.CHEVRON_RIGHT)) : null;
var breadcrumbs = new Breadcrumbs<Path>(); var breadcrumbs = new Breadcrumbs<Path>();
breadcrumbs.setAutoNavigationEnabled(false); breadcrumbs.setAutoNavigationEnabled(false);
breadcrumbs.setCrumbFactory(crumbFactory); breadcrumbs.setCrumbFactory(crumbFactory);
breadcrumbs.setSelectedCrumb( breadcrumbs.setDividerFactory(dividerFactory);
Breadcrumbs.buildTreeModel(getParentPath(model.currentPathProperty().get(), 4).toArray(Path[]::new)) breadcrumbs.setSelectedCrumb(Breadcrumbs.buildTreeModel(
getParentPath(model.currentPathProperty().get(), 4).toArray(Path[]::new))
); );
breadcrumbs.setMaxWidth(Region.USE_PREF_SIZE);
breadcrumbs.setMaxHeight(Region.USE_PREF_SIZE);
return breadcrumbs; return breadcrumbs;
} }

@ -11,6 +11,10 @@
-fx-background-insets: 0, 1; -fx-background-insets: 0, 1;
} }
.file-manager-showcase .breadcrumbs >.divider {
-fx-padding: 0;
}
.file-manager-showcase .table-directory-view .table-view { .file-manager-showcase .table-directory-view .table-view {
-color-header-bg: -color-bg-default; -color-header-bg: -color-bg-default;
-color-cell-bg-selected: -color-accent-emphasis; -color-cell-bg-selected: -color-accent-emphasis;

@ -2,36 +2,27 @@
@use "../settings/config" as cfg; @use "../settings/config" as cfg;
// node hierarchy $padding-x: cfg.$padding-x !default;
// .bread-crumb-bar { $padding-y: cfg.$padding-y !default;
// >.crumb { ... }
// }
// right gap is small, because we have crumbs divider on the right, $divider-spacing: 0.5em !default;
// which gives us additional padding, at least visually.
$crumb-right-gap: 2px !default;
$crumb-left-gap: calc(cfg.$graphic-gap * 1.5) !default;
.bread-crumb-bar { /**
>.button.flat { == Structure ==
-fx-padding: cfg.$padding-y $crumb-right-gap cfg.$padding-y $crumb-left-gap; .breadcrumbs {
-fx-content-display: RIGHT; >.crumb[:first|:last] { ... }
>.divider { ... }
}
*/
&.first { .breadcrumbs {
-fx-padding: cfg.$padding-y $crumb-right-gap cfg.$padding-y cfg.$padding-x; -fx-padding: $padding-y $padding-x $padding-y $padding-x;
>.hyperlink {
-color-link-fg-visited: -color-link-fg;
} }
&.last { >.label.divider {
-fx-padding: cfg.$padding-y cfg.$padding-x cfg.$padding-y $crumb-left-gap; -fx-padding: 0 $divider-spacing 0 $divider-spacing;
#{cfg.$font-icon-selector} {
// hiding font icon doesn't work as it should,
// and have to be replaced by '-fx-managed' when supported
-fx-min-width: 0;
-fx-pref-width: 0;
-fx-max-width: 0;
visibility: hidden;
}
}
} }
} }