From 34acefa8f88da14f6456eb845d82ae71d6a88990 Mon Sep 17 00:00:00 2001 From: mkpaz Date: Mon, 29 May 2023 07:44:27 +0400 Subject: [PATCH] Refactor and improve Tile-based controls --- .../java/atlantafx/base/controls/Message.java | 71 +++++- .../atlantafx/base/controls/MessageSkin.java | 78 +++++++ .../atlantafx/base/controls/SlotListener.java | 15 +- .../java/atlantafx/base/controls/Tile.java | 80 +------ .../atlantafx/base/controls/TileBase.java | 92 ++++++++ .../atlantafx/base/controls/TileSkin.java | 122 +---------- .../atlantafx/base/controls/TileSkinBase.java | 144 ++++++++++++ .../atlantafx/sampler/event/BrowseEvent.java | 4 + .../java/atlantafx/sampler/event/Event.java | 4 + .../sampler/page/components/CardPage.java | 4 +- .../sampler/page/components/MessagePage.java | 160 +++++++++----- .../sampler/page/components/TilePage.java | 64 ++++-- .../sampler/fxml/custom-controls.test.fxml | 2 +- .../atlantafx/sampler/fxml/overview.fxml | 2 +- styles/src/components/_message.scss | 205 +++++++++++------- styles/src/components/_tile.scss | 62 +++--- 16 files changed, 738 insertions(+), 371 deletions(-) create mode 100644 base/src/main/java/atlantafx/base/controls/MessageSkin.java create mode 100644 base/src/main/java/atlantafx/base/controls/TileBase.java create mode 100644 base/src/main/java/atlantafx/base/controls/TileSkinBase.java diff --git a/base/src/main/java/atlantafx/base/controls/Message.java b/base/src/main/java/atlantafx/base/controls/Message.java index 0447384..4af7c12 100644 --- a/base/src/main/java/atlantafx/base/controls/Message.java +++ b/base/src/main/java/atlantafx/base/controls/Message.java @@ -2,34 +2,93 @@ package atlantafx.base.controls; +import javafx.beans.NamedArg; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.Event; +import javafx.event.EventHandler; import javafx.scene.Node; +import javafx.scene.control.Skin; +import org.jetbrains.annotations.Nullable; /** * The Message is a component for displaying notifications or alerts * and is specifically designed to grab the user’s attention. * It is based on the Tile layout and shares its structure. */ -public class Message extends Tile { +public class Message extends TileBase { /** * See {@link Tile#Tile()}. */ public Message() { - super(null, null, null); + this(null, null, null); } /** * See {@link Tile#Tile(String, String)}. */ - public Message(String title, String subtitle) { - this(title, subtitle, null); + public Message(@Nullable @NamedArg("title") String title, + @Nullable @NamedArg("description") String description) { + this(title, description, null); } /** * See {@link Tile#Tile(String, String, Node)}. */ - public Message(String title, String subtitle, Node graphic) { - super(title, subtitle, graphic); + public Message(@Nullable String title, + @Nullable String description, + @Nullable Node graphic) { + super(title, description, graphic); getStyleClass().add("message"); } + + @Override + protected Skin createDefaultSkin() { + return new MessageSkin(this); + } + + /////////////////////////////////////////////////////////////////////////// + // Properties // + /////////////////////////////////////////////////////////////////////////// + + /** + * The property representing the message’s action handler. Setting an action handler + * makes the message interactive or clickable. When a user clicks on the interactive + * message, the specified action handler will be called. + */ + private final ObjectProperty actionHandler = new SimpleObjectProperty<>(this, "actionHandler"); + + public Runnable getActionHandler() { + return actionHandler.get(); + } + + public ObjectProperty actionHandlerProperty() { + return actionHandler; + } + + public void setActionHandler(Runnable actionHandler) { + this.actionHandler.set(actionHandler); + } + + /** + * The property representing the user specified close handler. Note that + * if you have also specified the ModalPane instance or CSS selector, this + * handler will be executed after the default close handler. Therefore, you + * can use it to perform arbitrary actions on dialog close. + */ + protected final ObjectProperty> onClose = + new SimpleObjectProperty<>(this, "onClose"); + + public EventHandler getOnClose() { + return onClose.get(); + } + + public ObjectProperty> onCloseProperty() { + return onClose; + } + + public void setOnClose(EventHandler onClose) { + this.onClose.set(onClose); + } } diff --git a/base/src/main/java/atlantafx/base/controls/MessageSkin.java b/base/src/main/java/atlantafx/base/controls/MessageSkin.java new file mode 100644 index 0000000..cd93d54 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/MessageSkin.java @@ -0,0 +1,78 @@ +/* SPDX-License-Identifier: MIT */ + +package atlantafx.base.controls; + +import atlantafx.base.theme.Styles; +import javafx.css.PseudoClass; +import javafx.event.Event; +import javafx.geometry.HPos; +import javafx.geometry.VPos; +import javafx.scene.layout.StackPane; + +public class MessageSkin extends TileSkinBase { + + private static final PseudoClass CLOSEABLE = PseudoClass.getPseudoClass("closeable"); + + protected final StackPane closeButton = new StackPane(); + protected final StackPane closeButtonIcon = new StackPane(); + + public MessageSkin(Message control) { + super(control); + + // ACTION + + pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null); + registerChangeListener( + control.actionHandlerProperty(), + o -> pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null) + ); + + root.setOnMouseClicked(e -> { + if (getSkinnable().getActionHandler() != null) { + getSkinnable().getActionHandler().run(); + } + }); + + // CLOSE BUTTON + + closeButton.getStyleClass().add("close-button"); + closeButton.getChildren().setAll(closeButtonIcon); + closeButton.setOnMouseClicked(e -> handleClose()); + closeButton.setVisible(control.getOnClose() != null); + closeButton.setManaged(control.getOnClose() != null); + + closeButtonIcon.getStyleClass().add("icon"); + getChildren().add(closeButton); + + pseudoClassStateChanged(CLOSEABLE, control.getOnClose() != null); + registerChangeListener(control.onCloseProperty(), o -> { + closeButton.setVisible(getSkinnable().getOnClose() != null); + closeButton.setManaged(getSkinnable().getOnClose() != null); + pseudoClassStateChanged(CLOSEABLE, getSkinnable().onCloseProperty() != null); + }); + } + + protected void handleClose() { + if (getSkinnable().getOnClose() != null) { + getSkinnable().getOnClose().handle(new Event(Event.ANY)); + } + } + + @Override + protected void layoutChildren(double x, double y, double w, double h) { + if (closeButton.isManaged()) { + var lb = closeButton.getLayoutBounds(); + layoutInArea(closeButton, w - lb.getWidth() - 5, 5, lb.getWidth(), lb.getHeight(), -1, HPos.RIGHT, + VPos.TOP); + } + layoutInArea(root, x, y, w, h, -1, HPos.CENTER, VPos.CENTER); + } + + @Override + public void dispose() { + unregisterChangeListeners(getSkinnable().actionHandlerProperty()); + unregisterChangeListeners(getSkinnable().onCloseProperty()); + + super.dispose(); + } +} diff --git a/base/src/main/java/atlantafx/base/controls/SlotListener.java b/base/src/main/java/atlantafx/base/controls/SlotListener.java index f438446..ec03a65 100644 --- a/base/src/main/java/atlantafx/base/controls/SlotListener.java +++ b/base/src/main/java/atlantafx/base/controls/SlotListener.java @@ -3,21 +3,30 @@ package atlantafx.base.controls; import java.util.Objects; +import java.util.function.BiConsumer; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.css.PseudoClass; import javafx.scene.Node; import javafx.scene.layout.Pane; +import org.jetbrains.annotations.Nullable; final class SlotListener implements ChangeListener { private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled"); private final Pane slot; + private final @Nullable BiConsumer onContentUpdate; - public SlotListener(Node slot) { + public SlotListener(Pane slot) { + this(slot, null); + } + + public SlotListener(Node slot, @Nullable BiConsumer onContentUpdate) { Objects.requireNonNull(slot, "Slot cannot be null."); + this.onContentUpdate = onContentUpdate; + if (slot instanceof Pane pane) { this.slot = pane; } else { @@ -35,5 +44,9 @@ final class SlotListener implements ChangeListener { slot.setVisible(val != null); slot.setManaged(val != null); slot.pseudoClassStateChanged(FILLED, val != null); + + if (onContentUpdate != null) { + onContentUpdate.accept(val, val != null); + } } } diff --git a/base/src/main/java/atlantafx/base/controls/Tile.java b/base/src/main/java/atlantafx/base/controls/Tile.java index 7621779..b34cca0 100644 --- a/base/src/main/java/atlantafx/base/controls/Tile.java +++ b/base/src/main/java/atlantafx/base/controls/Tile.java @@ -5,35 +5,31 @@ package atlantafx.base.controls; import javafx.beans.NamedArg; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; import javafx.scene.Node; -import javafx.scene.control.Control; import javafx.scene.control.Skin; +import org.jetbrains.annotations.Nullable; /** * A versatile container that can used in various contexts such as dialog headers, - * list items, and cards. It can contain a graphic, a title, subtitle, and optional + * list items, and cards. It can contain a graphic, a title, description, and optional * actions. */ -public class Tile extends Control { +public class Tile extends TileBase { public Tile() { this(null, null, null); } - public Tile(@NamedArg("title") String title, - @NamedArg("subTitle") String subTitle) { - this(title, subTitle, null); + public Tile(@Nullable @NamedArg("title") String title, + @Nullable @NamedArg("description") String description) { + this(title, description, null); } - public Tile(@NamedArg("title") String title, - @NamedArg("subTitle") String subTitle, - @NamedArg("graphic") Node graphic) { - super(); - setTitle(title); - setSubTitle(subTitle); - setGraphic(graphic); + public Tile(@Nullable String title, + @Nullable String description, + @Nullable Node graphic) { + super(title, description, graphic); + getStyleClass().add("tile"); } @Override @@ -45,60 +41,6 @@ public class Tile extends Control { // Properties // /////////////////////////////////////////////////////////////////////////// - /** - * The property representing the tile’s graphic node. It is commonly used - * to add images or icons that are associated with the tile. - */ - private final ObjectProperty graphic = new SimpleObjectProperty<>(this, "graphic"); - - public Node getGraphic() { - return graphic.get(); - } - - public ObjectProperty graphicProperty() { - return graphic; - } - - public void setGraphic(Node graphic) { - this.graphic.set(graphic); - } - - /** - * The property representing the tile’s title. Although it is not mandatory, - * you typically would not want to have a tile without a title. - */ - private final StringProperty title = new SimpleStringProperty(this, "title"); - - public String getTitle() { - return title.get(); - } - - public StringProperty titleProperty() { - return title; - } - - public void setTitle(String title) { - this.title.set(title); - } - - /** - * The property representing the tile’s subtitle. This is usually an optional - * text or a description. - */ - private final StringProperty subTitle = new SimpleStringProperty(this, "subTitle"); - - public String getSubTitle() { - return subTitle.get(); - } - - public StringProperty subTitleProperty() { - return subTitle; - } - - public void setSubTitle(String subTitle) { - this.subTitle.set(subTitle); - } - /** * The property representing the tile’s action node. It is commonly used * to place an action controls that are associated with the tile. diff --git a/base/src/main/java/atlantafx/base/controls/TileBase.java b/base/src/main/java/atlantafx/base/controls/TileBase.java new file mode 100644 index 0000000..91a1541 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/TileBase.java @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ + +package atlantafx.base.controls; + +import javafx.beans.NamedArg; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.scene.Node; +import javafx.scene.control.Control; +import org.jetbrains.annotations.Nullable; + +public abstract class TileBase extends Control { + + public TileBase() { + this(null, null, null); + } + + public TileBase(@Nullable @NamedArg("title") String title, + @Nullable @NamedArg("description") String description) { + this(title, description, null); + } + + public TileBase(@Nullable String title, + @Nullable String description, + @Nullable Node graphic) { + super(); + + setTitle(title); + setDescription(description); + setGraphic(graphic); + getStyleClass().add("tile-base"); + } + + /////////////////////////////////////////////////////////////////////////// + // Properties // + /////////////////////////////////////////////////////////////////////////// + + /** + * The property representing the tile’s graphic node. It is commonly used + * to add images or icons that are associated with the tile. + */ + private final ObjectProperty graphic = new SimpleObjectProperty<>(this, "graphic"); + + public Node getGraphic() { + return graphic.get(); + } + + public ObjectProperty graphicProperty() { + return graphic; + } + + public void setGraphic(Node graphic) { + this.graphic.set(graphic); + } + + /** + * The property representing the tile’s title. Although it is not mandatory, + * you typically would not want to have a tile without a title. + */ + private final StringProperty title = new SimpleStringProperty(this, "title"); + + public String getTitle() { + return title.get(); + } + + public StringProperty titleProperty() { + return title; + } + + public void setTitle(String title) { + this.title.set(title); + } + + /** + * The property representing the tile’s description. + */ + private final StringProperty description = new SimpleStringProperty(this, "description"); + + public String getDescription() { + return description.get(); + } + + public StringProperty descriptionProperty() { + return description; + } + + public void setDescription(String description) { + this.description.set(description); + } +} diff --git a/base/src/main/java/atlantafx/base/controls/TileSkin.java b/base/src/main/java/atlantafx/base/controls/TileSkin.java index e7c34ab..e2aa1a3 100644 --- a/base/src/main/java/atlantafx/base/controls/TileSkin.java +++ b/base/src/main/java/atlantafx/base/controls/TileSkin.java @@ -3,140 +3,30 @@ package atlantafx.base.controls; import atlantafx.base.theme.Styles; -import javafx.beans.value.ChangeListener; -import javafx.css.PseudoClass; -import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.control.SkinBase; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -public class TileSkin extends SkinBase { - - private static final PseudoClass WITH_TITLE = PseudoClass.getPseudoClass("with-title"); - private static final PseudoClass WITH_SUBTITLE = PseudoClass.getPseudoClass("with-subtitle"); - - protected final HBox root = new HBox(); - protected final StackPane graphicSlot; - protected final ChangeListener graphicSlotListener; - protected final VBox headerBox; - protected final Label titleLbl; - protected final Label subTitleLbl; - protected final StackPane actionSlot; - protected final ChangeListener actionSlotListener; +public class TileSkin extends TileSkinBase { public TileSkin(Tile control) { super(control); - graphicSlot = new StackPane(); - graphicSlot.getStyleClass().add("graphic"); - graphicSlotListener = new SlotListener(graphicSlot); - control.graphicProperty().addListener(graphicSlotListener); - graphicSlotListener.changed(control.graphicProperty(), null, control.getGraphic()); - - titleLbl = new Label(control.getTitle()); - titleLbl.getStyleClass().add("title"); - titleLbl.setVisible(control.getTitle() != null); - titleLbl.setManaged(control.getTitle() != null); - - subTitleLbl = new Label(control.getSubTitle()); - subTitleLbl.setWrapText(true); - subTitleLbl.getStyleClass().add("subtitle"); - subTitleLbl.setVisible(control.getSubTitle() != null); - subTitleLbl.setManaged(control.getSubTitle() != null); - - headerBox = new VBox(titleLbl, subTitleLbl); - headerBox.setFillWidth(true); - headerBox.getStyleClass().add("header"); - HBox.setHgrow(headerBox, Priority.ALWAYS); - headerBox.setMinHeight(Region.USE_COMPUTED_SIZE); - headerBox.setPrefHeight(Region.USE_COMPUTED_SIZE); - headerBox.setMaxHeight(Region.USE_COMPUTED_SIZE); - - - root.pseudoClassStateChanged(WITH_TITLE, control.getTitle() != null); - registerChangeListener(control.titleProperty(), o -> { - var value = getSkinnable().getSubTitle(); - titleLbl.setText(value); - titleLbl.setVisible(value != null); - titleLbl.setManaged(value != null); - root.pseudoClassStateChanged(WITH_TITLE, value != null); - }); - - root.pseudoClassStateChanged(WITH_SUBTITLE, control.getSubTitle() != null); - registerChangeListener(control.subTitleProperty(), o -> { - var value = getSkinnable().getSubTitle(); - subTitleLbl.setText(value); - subTitleLbl.setVisible(value != null); - subTitleLbl.setManaged(value != null); - root.pseudoClassStateChanged(WITH_SUBTITLE, value != null); - }); - - actionSlot = new StackPane(); - actionSlot.getStyleClass().add("action"); - actionSlotListener = new SlotListener(actionSlot); control.actionProperty().addListener(actionSlotListener); actionSlotListener.changed(control.actionProperty(), null, control.getAction()); - // use pref size for slots, or they will be resized - // to the bare minimum due to Priority.ALWAYS - graphicSlot.setMinWidth(Region.USE_PREF_SIZE); - actionSlot.setMinWidth(Region.USE_PREF_SIZE); - - // label text wrapping inside VBox won't work without this - subTitleLbl.setMaxWidth(Region.USE_PREF_SIZE); - subTitleLbl.setMinHeight(Region.USE_PREF_SIZE); - - // do not resize children or container won't restore - // to its original size after expanding - root.setFillHeight(false); - - root.getStyleClass().add("tile"); - root.getChildren().setAll(graphicSlot, headerBox, actionSlot); - - root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null); + pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null); registerChangeListener( control.actionHandlerProperty(), - o -> root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null) + o -> pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null) ); + root.setOnMouseClicked(e -> { - if (control.getActionHandler() != null) { - control.getActionHandler().run(); + if (getSkinnable().getActionHandler() != null) { + getSkinnable().getActionHandler().run(); } }); - - getChildren().setAll(root); - } - - protected double calcHeight() { - var headerHeight = headerBox.getSpacing() - + headerBox.getInsets().getTop() - + headerBox.getInsets().getBottom() - + titleLbl.getBoundsInLocal().getHeight() - + (subTitleLbl.isManaged() ? subTitleLbl.getBoundsInLocal().getHeight() : 0); - - return Math.max(Math.max(graphicSlot.getHeight(), actionSlot.getHeight()), headerHeight) - + root.getPadding().getTop() - + root.getPadding().getBottom(); - } - - @Override - protected double computeMinHeight(double width, double topInset, double rightInset, - double bottomInset, double leftInset) { - // change the control height when label changed its height due to text wrapping, - // no other way to do that, all JavaFX containers completely ignore _the actual_ - // height of its children - return calcHeight(); } @Override public void dispose() { - unregisterChangeListeners(getSkinnable().titleProperty()); - unregisterChangeListeners(getSkinnable().subTitleProperty()); - getSkinnable().graphicProperty().removeListener(graphicSlotListener); getSkinnable().actionProperty().removeListener(actionSlotListener); unregisterChangeListeners(getSkinnable().actionHandlerProperty()); diff --git a/base/src/main/java/atlantafx/base/controls/TileSkinBase.java b/base/src/main/java/atlantafx/base/controls/TileSkinBase.java new file mode 100644 index 0000000..27ba0ba --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/TileSkinBase.java @@ -0,0 +1,144 @@ +/* SPDX-License-Identifier: MIT */ + +package atlantafx.base.controls; + +import atlantafx.base.util.BBCodeParser; +import javafx.beans.value.ChangeListener; +import javafx.css.PseudoClass; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.SkinBase; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.TextFlow; + +public abstract class TileSkinBase extends SkinBase { + + protected static final PseudoClass HAS_GRAPHIC = PseudoClass.getPseudoClass("has-graphic"); + protected static final PseudoClass HAS_TITLE = PseudoClass.getPseudoClass("has-title"); + protected static final PseudoClass HAS_DESCRIPTION = PseudoClass.getPseudoClass("has-description"); + protected static final PseudoClass HAS_ACTION = PseudoClass.getPseudoClass("has-action"); + + protected final HBox root = new HBox(); + protected final StackPane graphicSlot; + protected final ChangeListener graphicSlotListener; + protected final VBox headerBox; + protected final Label titleLbl; + protected final TextFlow descriptionText; + protected final StackPane actionSlot; + protected final ChangeListener actionSlotListener; + + public TileSkinBase(T control) { + super(control); + + graphicSlot = new StackPane(); + graphicSlot.getStyleClass().add("graphic"); + graphicSlotListener = new SlotListener( + graphicSlot, (n, active) -> getSkinnable().pseudoClassStateChanged(HAS_GRAPHIC, active) + ); + control.graphicProperty().addListener(graphicSlotListener); + graphicSlotListener.changed(control.graphicProperty(), null, control.getGraphic()); + + titleLbl = new Label(control.getTitle()); + titleLbl.getStyleClass().add("title"); + titleLbl.setVisible(control.getTitle() != null); + titleLbl.setManaged(control.getTitle() != null); + + descriptionText = new TextFlow(); + descriptionText.getStyleClass().add("description"); + descriptionText.setVisible(control.getDescription() != null); + descriptionText.setManaged(control.getDescription() != null); + setDescriptionText(); + + headerBox = new VBox(titleLbl, descriptionText); + headerBox.setFillWidth(true); + headerBox.getStyleClass().add("header"); + HBox.setHgrow(headerBox, Priority.ALWAYS); + headerBox.setMinHeight(Region.USE_COMPUTED_SIZE); + headerBox.setPrefHeight(Region.USE_COMPUTED_SIZE); + headerBox.setMaxHeight(Region.USE_COMPUTED_SIZE); + + control.pseudoClassStateChanged(HAS_TITLE, control.getTitle() != null); + registerChangeListener(control.titleProperty(), o -> { + var value = getSkinnable().getDescription(); + titleLbl.setText(value); + titleLbl.setVisible(value != null); + titleLbl.setManaged(value != null); + getSkinnable().pseudoClassStateChanged(HAS_TITLE, value != null); + }); + + control.pseudoClassStateChanged(HAS_DESCRIPTION, control.getDescription() != null); + registerChangeListener(control.descriptionProperty(), o -> { + var value = getSkinnable().getDescription(); + setDescriptionText(); + descriptionText.setVisible(value != null); + descriptionText.setManaged(value != null); + getSkinnable().pseudoClassStateChanged(HAS_DESCRIPTION, value != null); + }); + + actionSlot = new StackPane(); + actionSlot.getStyleClass().add("action"); + actionSlotListener = new SlotListener( + actionSlot, (n, active) -> getSkinnable().pseudoClassStateChanged(HAS_ACTION, active) + ); + + // use pref size for slots, or they will be resized + // to the bare minimum due to Priority.ALWAYS + graphicSlot.setMinWidth(Region.USE_PREF_SIZE); + actionSlot.setMinWidth(Region.USE_PREF_SIZE); + + // label text wrapping inside VBox won't work without this + descriptionText.setMaxWidth(Region.USE_PREF_SIZE); + descriptionText.setMinHeight(Region.USE_PREF_SIZE); + + // do not resize children or container won't restore + // to its original size after expanding + root.setFillHeight(false); + + root.getChildren().setAll(graphicSlot, headerBox, actionSlot); + root.getStyleClass().add("container"); + getChildren().setAll(root); + } + + protected void setDescriptionText() { + if (!descriptionText.getChildren().isEmpty()) { + descriptionText.getChildren().clear(); + } + if (getSkinnable().getDescription() != null && !getSkinnable().getDescription().isBlank()) { + BBCodeParser.createLayout(getSkinnable().getDescription(), descriptionText); + } + } + + protected double calcHeight() { + var headerHeight = headerBox.getSpacing() + + headerBox.getInsets().getTop() + + headerBox.getInsets().getBottom() + + titleLbl.getBoundsInLocal().getHeight() + + (descriptionText.isManaged() ? descriptionText.getBoundsInLocal().getHeight() : 0); + + return Math.max(Math.max(graphicSlot.getHeight(), actionSlot.getHeight()), headerHeight) + + root.getPadding().getTop() + + root.getPadding().getBottom(); + } + + @Override + protected double computeMinHeight(double width, double topInset, double rightInset, + double bottomInset, double leftInset) { + // change the control height when label changed its height due to text wrapping, + // no other way to do that, all JavaFX containers completely ignore _the actual_ + // height of its children + return calcHeight(); + } + + @Override + public void dispose() { + unregisterChangeListeners(getSkinnable().titleProperty()); + unregisterChangeListeners(getSkinnable().descriptionProperty()); + getSkinnable().graphicProperty().removeListener(graphicSlotListener); + + super.dispose(); + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java b/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java index 9dc0af2..cc34bba 100644 --- a/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java +++ b/sampler/src/main/java/atlantafx/sampler/event/BrowseEvent.java @@ -22,4 +22,8 @@ public final class BrowseEvent extends Event { + "uri=" + uri + "} " + super.toString(); } + + public static void fire(String url) { + Event.publish(new BrowseEvent(URI.create(url))); + } } diff --git a/sampler/src/main/java/atlantafx/sampler/event/Event.java b/sampler/src/main/java/atlantafx/sampler/event/Event.java index dabc83e..05046b3 100644 --- a/sampler/src/main/java/atlantafx/sampler/event/Event.java +++ b/sampler/src/main/java/atlantafx/sampler/event/Event.java @@ -37,4 +37,8 @@ public abstract class Event { + "id=" + id + '}'; } + + public static void publish(E event) { + DefaultEventBus.getInstance().publish(event); + } } diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/CardPage.java b/sampler/src/main/java/atlantafx/sampler/page/components/CardPage.java index dcd9653..cbb820e 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/components/CardPage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/components/CardPage.java @@ -148,7 +148,7 @@ public class CardPage extends OutlinePage { card1.setMaxWidth(250); card1.setHeader(new Tile( "This is a title", - "This is a subtitle" + "This is a description" )); card1.setBody(new Label("This is content")); @@ -158,7 +158,7 @@ public class CardPage extends OutlinePage { card2.setMaxWidth(250); card2.setHeader(new Tile( "This is a title", - "This is a subtitle" + "This is a description" )); card2.setBody(new Label("This is content")); //snippet_2:end diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java b/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java index ceeeebc..86711e4 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java @@ -1,20 +1,20 @@ package atlantafx.sampler.page.components; -import atlantafx.base.controls.Message; import atlantafx.base.theme.Styles; -import atlantafx.base.theme.Tweaks; import atlantafx.base.util.Animations; import atlantafx.base.util.BBCodeParser; +import atlantafx.base.controls.Message; +import atlantafx.sampler.event.BrowseEvent; import atlantafx.sampler.page.ExampleBox; import atlantafx.sampler.page.OutlinePage; import atlantafx.sampler.page.Snippet; import java.net.URI; +import javafx.event.ActionEvent; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.Button; -import javafx.scene.layout.BorderPane; +import javafx.scene.control.Hyperlink; import javafx.scene.layout.VBox; -import javafx.util.Duration; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.material2.Material2OutlinedAL; import org.kordamp.ikonli.material2.Material2OutlinedMZ; @@ -38,27 +38,60 @@ public class MessagePage extends OutlinePage { addPageHeader(); addFormattedText(""" - The [i]Message[/i] is a component for displaying notifications or alerts \ - and is specifically designed to grab the user’s attention. It is \ - based on the [i]Tile[/i] layout and shares its structure.""" + The [i]Message[/i] is a component for displaying an important text or \ + alerts and is specifically designed to grab the user’s attention. It is \ + based on the [i]Tile[/i] layout and shares its structure, except it doesn't \ + provide the action slot.""" ); addSection("Usage", usageExample()); - addSection("No Title", noTitleExample()); + addSection("Intent", intentExample()); + addSection("Incomplete Header", incompleteHeaderExample()); addSection("Interactive", interactiveExample()); - addSection("Banner", bannerExample()); + addSection("Closeable", closeableExample()); } private Node usageExample() { + // won't work inside the snippet, because code + // snippet use BBCode parse as well + var url = "https://wikipedia.org/wiki/The_Elder_Scrolls_III:_Morrowind"; + var quote = FAKER.elderScrolls().quote() + + " \n[url=" + url + "]Learn more[/url]"; + //snippet_1:start + var msg = new Message( + "Quote", + quote, + new FontIcon(Material2OutlinedAL.CHAT_BUBBLE_OUTLINE) + ); + msg.addEventFilter(ActionEvent.ACTION, e -> { + if (e.getTarget() instanceof Hyperlink link) { + BrowseEvent.fire((String) link.getUserData()); + } + e.consume(); + }); + //snippet_1:end + + var box = new VBox(msg); + var description = BBCodeParser.createFormattedText(""" + [i]Message[/i] does not have any mandatory properties. It supports text \ + wrapping and [i]BBCode[/i] formatted text, but only in the description field.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 1), description); + } + + private Node intentExample() { + //snippet_5:start var info = new Message( "Info", - FAKER.lorem().sentence(5), + FAKER.lorem().sentence(10), new FontIcon(Material2OutlinedAL.HELP_OUTLINE) ); + info.getStyleClass().add(Styles.ACCENT); var success = new Message( "Success", - FAKER.lorem().sentence(10), + FAKER.lorem().sentence(15), new FontIcon(Material2OutlinedAL.CHECK_CIRCLE_OUTLINE) ); success.getStyleClass().add(Styles.SUCCESS); @@ -76,45 +109,39 @@ public class MessagePage extends OutlinePage { new FontIcon(Material2OutlinedAL.ERROR_OUTLINE) ); danger.getStyleClass().add(Styles.DANGER); - //snippet_1:end + //snippet_5:end var box = new VBox(VGAP_10, info, success, warning, danger); var description = BBCodeParser.createFormattedText(""" - The default [i]Message[/i] type is "info", which corresponds to the \ - accent color. Success, warning, and danger colors are also supported.""" + The [i]Message[/i] offers four severity levels that set a distinctive color. \ + To change the [i]Message[/i] intent, use the corresponding style class modifier.""" ); - return new ExampleBox(box, new Snippet(getClass(), 1), description); + return new ExampleBox(box, new Snippet(getClass(), 5), description); } - private Node noTitleExample() { + private Node incompleteHeaderExample() { //snippet_2:start var info1 = new Message( - null, FAKER.lorem().sentence(5), + null, new FontIcon(Material2OutlinedAL.HELP_OUTLINE) ); + info1.getStyleClass().add(Styles.ACCENT); var info2 = new Message( - null, - FAKER.lorem().sentence(15) - ); - - var info3 = new Message( null, FAKER.lorem().sentence(50) ); - var btn = new Button("Done"); - btn.getStyleClass().add(Styles.ACCENT); - info3.setAction(btn); + info2.getStyleClass().add(Styles.ACCENT); //snippet_2:end - var box = new VBox(VGAP_10, info1, info2, info3); + var box = new VBox(VGAP_10, info1, info2); var description = BBCodeParser.createFormattedText(""" - Unlike the [i]Tile[/i], the message title is optional. This example \ - demonstrates various messages without a title.""" + Both the title and description are completely optional, but one them has to be \ + specified in any case. Note that the styling changes depending on whether the [i]Message[/i] \ + has only a title, only a description, or both.""" ); - return new ExampleBox(box, new Snippet(getClass(), 2), description); } @@ -128,57 +155,72 @@ public class MessagePage extends OutlinePage { var btn = new Button("Undo"); btn.getStyleClass().addAll(Styles.SUCCESS); - - msg.setAction(btn); msg.setActionHandler(() -> Animations.flash(msg).playFromStart()); //snippet_3:end var box = new VBox(msg); box.setPadding(new Insets(0, 0, 5, 0)); var description = BBCodeParser.createFormattedText(""" - A [i]Message[/i] can be made interactive by setting an action handler that may \ - or may not be related to the action slot.""" + A [i]Message[/i] can be made interactive by setting an action handler. \ + This allows to call any arbitrary action when the user clicks inside \ + the message container. For example, you could show an extended dialog or \ + trigger a notification panel to appear..""" ); return new ExampleBox(box, new Snippet(getClass(), 3), description); } - private Node bannerExample() { + private Node closeableExample() { //snippet_4:start - final var msg = new Message( - null, - FAKER.lorem().sentence(10) + var regular = new Message( + "Regular", + FAKER.lorem().sentence(5), + new FontIcon(Material2OutlinedAL.CHAT_BUBBLE_OUTLINE) ); - msg.getStyleClass().addAll( - Styles.DANGER, Tweaks.EDGE_TO_EDGE + regular.setOnClose(e -> Animations.flash(regular).playFromStart()); + + var info = new Message( + "Info", + FAKER.lorem().sentence(10), + new FontIcon(Material2OutlinedAL.HELP_OUTLINE) ); + info.getStyleClass().add(Styles.ACCENT); + info.setOnClose(e -> Animations.flash(info).playFromStart()); - var closeBtn = new Button("Close"); - closeBtn.getStyleClass().addAll(Styles.DANGER); - msg.setAction(closeBtn); + var success = new Message( + "Success", + FAKER.lorem().sentence(15), + new FontIcon(Material2OutlinedAL.CHECK_CIRCLE_OUTLINE) + ); + success.getStyleClass().add(Styles.SUCCESS); + success.setOnClose(e -> Animations.flash(success).playFromStart()); - var showBannerBtn = new Button("Show banner"); - showBannerBtn.setOnAction(e1 -> { - var parent = (BorderPane) getScene().lookup("#main"); + var warning = new Message( + "Warning", + FAKER.lorem().sentence(20), + new FontIcon(Material2OutlinedMZ.OUTLINED_FLAG) + ); + warning.getStyleClass().add(Styles.WARNING); + warning.setOnClose(e -> Animations.flash(warning).playFromStart()); - parent.setTop(new VBox(msg)); - closeBtn.setOnAction(e2 -> parent.setTop(null)); - - msg.setOpacity(0); - Animations.fadeInDown(msg, Duration.millis(350)) - .playFromStart(); - }); + var danger = new Message( + "Danger", + FAKER.lorem().sentence(25), + new FontIcon(Material2OutlinedAL.ERROR_OUTLINE) + ); + danger.getStyleClass().add(Styles.DANGER); + danger.setOnClose(e -> Animations.flash(danger).playFromStart()); //snippet_4:end - var box = new VBox(showBannerBtn); + var box = new VBox(VGAP_10, regular, info, success, warning, danger); var description = BBCodeParser.createFormattedText(""" - The [i]Message[/i] supports the [code]Tweaks.EDGE_TO_EDGE[/code] style class modifier, \ - which can be used to create a fancy banner, for example.""" + You can make the [i]Message[/i] closeable by setting an appropriate message handler. \ + If the handler is set, the close button will appear in the top right corner of the \ + [i]Message[/i]. This handler should provide some logic for removing the [i]Message[/i] \ + from its parent container as no default implementation is provided.""" ); + box.setPadding(new Insets(0, 0, 5, 0)); - var example = new ExampleBox(box, new Snippet(getClass(), 4), description); - example.setAllowDisable(false); - - return example; + return new ExampleBox(box, new Snippet(getClass(), 4), description); } } diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java b/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java index f578858..e35a0cc 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java @@ -1,23 +1,27 @@ package atlantafx.sampler.page.components; import atlantafx.base.controls.PasswordTextField; -import atlantafx.base.controls.Tile; import atlantafx.base.controls.ToggleSwitch; import atlantafx.base.theme.Styles; +import atlantafx.base.util.Animations; import atlantafx.base.util.BBCodeParser; import atlantafx.sampler.Resources; +import atlantafx.base.controls.Tile; +import atlantafx.sampler.event.BrowseEvent; import atlantafx.sampler.page.ExampleBox; import atlantafx.sampler.page.OutlinePage; import atlantafx.sampler.page.Snippet; import java.net.URI; import java.util.function.BiFunction; import javafx.collections.FXCollections; +import javafx.event.ActionEvent; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; +import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.control.Separator; import javafx.scene.control.Spinner; @@ -28,6 +32,7 @@ import javafx.scene.layout.GridPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.material2.Material2AL; import org.kordamp.ikonli.material2.Material2OutlinedAL; public class TilePage extends OutlinePage { @@ -51,12 +56,13 @@ public class TilePage extends OutlinePage { addFormattedText(""" The Tile is a versatile container that can used in various contexts \ such as dialog headers, list items, and cards. It can contain a graphic, \ - a title, subtitle, and optional actions.""" + a title, description, and optional actions.""" ); addNode(skeleton()); addSection("Usage", usageExample()); addSection("Interactive", interactiveExample()); addSection("Stacking", stackingExample()); + addSection("Incomplete Header", incompleteHeaderExample()); } private Node skeleton() { @@ -79,7 +85,7 @@ public class TilePage extends OutlinePage { grid.setMaxWidth(600); grid.add(cellBuilder.apply("Graphic", Pos.CENTER), 0, 0, 1, GridPane.REMAINING); grid.add(cellBuilder.apply("Title", Pos.CENTER_LEFT), 1, 0, 1, 1); - grid.add(cellBuilder.apply("Subtitle", Pos.CENTER_LEFT), 1, 1, 1, 1); + grid.add(cellBuilder.apply("Description", Pos.CENTER_LEFT), 1, 1, 1, 1); grid.add(cellBuilder.apply("Action", Pos.CENTER), 2, 0, 1, GridPane.REMAINING); grid.getColumnConstraints().setAll( new ColumnConstraints(-1, -1, -1, Priority.NEVER, HPos.CENTER, true), @@ -91,16 +97,25 @@ public class TilePage extends OutlinePage { } private Node usageExample() { + // won't work inside the snippet, because code + // snippet use BBCode parse as well + var url = "https://wikipedia.org/wiki/The_Elder_Scrolls_III:_Morrowind"; + var quote = FAKER.elderScrolls().quote() + + " \n[url=" + url + "]Learn more[/url]"; + //snippet_1:start var tile1 = new Tile( - "Title", - FAKER.lorem().sentence(15) + "Multiline Description", + FAKER.lorem().sentence(50) ); - var tile2 = new Tile( - FAKER.name().fullName(), - FAKER.elderScrolls().quote() - ); + var tile2 = new Tile(FAKER.name().fullName(), quote); + tile2.addEventFilter(ActionEvent.ACTION, e -> { + if (e.getTarget() instanceof Hyperlink link) { + BrowseEvent.fire((String) link.getUserData()); + } + e.consume(); + }); var img = new ImageView(new Image( Resources.getResourceAsStream("images/avatars/avatar1.png") @@ -121,9 +136,8 @@ public class TilePage extends OutlinePage { tile3 ); var description = BBCodeParser.createFormattedText(""" - [i]Tile[/i] does not have any mandatory properties, but you will not want \ - to use it without a title. Additionally, note that only the subtitle supports \ - text wrapping.""" + [i]Tile[/i] does not have any mandatory properties. It supports text \ + wrapping and [i]BBCode[/i] formatted text, but only in the description field.""" ); return new ExampleBox(box, new Snippet(getClass(), 1), description); @@ -133,7 +147,7 @@ public class TilePage extends OutlinePage { //snippet_2:start var tile = new Tile( "Password", - "Please enter your authentication password to unlock the content" + "Please enter your authentication password" ); var tf = new PasswordTextField(null); @@ -197,4 +211,28 @@ public class TilePage extends OutlinePage { return new ExampleBox(box, new Snippet(getClass(), 3), description); } + + private Node incompleteHeaderExample() { + //snippet_4:start + var tile1 = new Tile("Go to the next screen", null); + tile1.setAction(new FontIcon(Material2AL.ARROW_RIGHT)); + tile1.setActionHandler(() -> + Animations.wobble(tile1).playFromStart() + ); + + var tile2 = new Tile( + null, FAKER.friends().quote(), + new FontIcon(Material2OutlinedAL.FORMAT_QUOTE) + ); + //snippet_4:end + + var box = new VBox(tile1, new Separator(), tile2); + var description = BBCodeParser.createFormattedText(""" + Both the title and description are completely optional, but one them has to be \ + specified in any case. Note that the styling changes depending on whether the [i]Tile[/i] \ + has only a title, only a description, or both.\"""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 4), description); + } } diff --git a/sampler/src/main/resources/atlantafx/sampler/fxml/custom-controls.test.fxml b/sampler/src/main/resources/atlantafx/sampler/fxml/custom-controls.test.fxml index 3cf2de0..c8433b8 100644 --- a/sampler/src/main/resources/atlantafx/sampler/fxml/custom-controls.test.fxml +++ b/sampler/src/main/resources/atlantafx/sampler/fxml/custom-controls.test.fxml @@ -32,7 +32,7 @@