feat(control): add IconBadge notification-dot overlay component
Reusable FontIcon + colored corner dot (AtlantaFx ACCENT/SUCCESS/WARNING/DANGER), sized off the icon alone so the badge never grows the icon's footprint. Wire it into NotificationsWidget, dropping the old text count label. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package org.icroco.pholio.ui.control;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
|
||||
/**
|
||||
* The semantic colors {@link IconBadge}'s dot can render in — AtlantaFx's own vocabulary
|
||||
* ({@link Styles#ACCENT}/{@link Styles#SUCCESS}/{@link Styles#WARNING}/{@link Styles#DANGER}; AtlantaFx has
|
||||
* no "error", only DANGER). {@link #styleClass()} is swapped on the dot the same way real controls in this
|
||||
* codebase already add/remove {@code Styles.DANGER} etc. as a plain style class (see {@code ToastLayer} and
|
||||
* {@code AppHeaderBar}'s delete button) — AtlantaFx's own theme never applies these classes to a bare shape,
|
||||
* so {@code pholio.css} supplies the matching {@code .icon-badge-dot.<name>} rules itself.
|
||||
*/
|
||||
public enum ENotificationBadgeColor {
|
||||
ACCENT(Styles.ACCENT),
|
||||
SUCCESS(Styles.SUCCESS),
|
||||
WARNING(Styles.WARNING),
|
||||
DANGER(Styles.DANGER);
|
||||
|
||||
private final String styleClass;
|
||||
|
||||
ENotificationBadgeColor(String styleClass) {
|
||||
this.styleClass = styleClass;
|
||||
}
|
||||
|
||||
public String styleClass() {
|
||||
return styleClass;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.icroco.pholio.ui.control;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.shape.Circle;
|
||||
import org.icroco.pholio.ui.common.FxUtils;
|
||||
import org.icroco.pholio.ui.shell.EIconSize;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.kordamp.ikonli.Ikon;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A {@link FontIcon} with a small colored "notification dot" overlaid on its top-right corner — presence,
|
||||
* not a count (see {@code NotificationsWidget}'s own separate text count for that). Same {@link StackPane}
|
||||
* + {@link StackPane#setAlignment} + {@link StackPane#setMargin} overlay idiom
|
||||
* {@code GalleryRowCell.cornerIcon} already uses for thumbnail corner badges, just with a plain
|
||||
* {@link Circle} instead of a second icon.
|
||||
*
|
||||
* <p>Both {@link #badgeVisibleProperty()} and {@link #badgeColorProperty()} are plain JavaFX properties so
|
||||
* a caller can bind rather than push updates imperatively, e.g.
|
||||
* {@code badgeVisibleProperty().bind(notificationsManager.countBinding().greaterThan(0))}.
|
||||
*/
|
||||
public class IconBadge extends StackPane {
|
||||
|
||||
private static final double DOT_DIAMETER = 8;
|
||||
|
||||
/**
|
||||
* Negative on purpose: a flush (0 or positive) margin leaves the dot's bounding box entirely inside the
|
||||
* icon's corner, tangent to it at a single point — not the overlapping "sitting on the corner" look a
|
||||
* notification badge actually has. Shifting the TOP_RIGHT-aligned box by -2 lets part of the dot poke
|
||||
* past the icon's top and right edges.
|
||||
*/
|
||||
private static final Insets DOT_MARGIN = new Insets(-2);
|
||||
|
||||
private final FontIcon icon;
|
||||
private final Circle dot = new Circle(DOT_DIAMETER / 2);
|
||||
|
||||
private final BooleanProperty badgeVisible = new SimpleBooleanProperty(this, "badgeVisible", false);
|
||||
private final ObjectProperty<ENotificationBadgeColor> badgeColor =
|
||||
new SimpleObjectProperty<>(this, "badgeColor", ENotificationBadgeColor.ACCENT);
|
||||
|
||||
public IconBadge(FontIcon icon) {
|
||||
this.icon = icon;
|
||||
getStyleClass().add("icon-badge");
|
||||
|
||||
dot.getStyleClass().add("icon-badge-dot");
|
||||
dot.visibleProperty().bind(badgeVisible);
|
||||
dot.managedProperty().bind(badgeVisible);
|
||||
StackPane.setAlignment(dot, Pos.TOP_RIGHT);
|
||||
StackPane.setMargin(dot, DOT_MARGIN);
|
||||
|
||||
badgeColor.addListener((obs, previous, updated) -> applyColor(previous, updated));
|
||||
applyColor(null, badgeColor.get());
|
||||
|
||||
getChildren().addAll(icon, dot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sized off {@link #icon} alone, never {@link #dot} — a plain {@link StackPane} sizes itself off every
|
||||
* managed child, so once the dot becomes visible (hence managed, see the constructor's
|
||||
* {@code dot.managedProperty().bind(badgeVisible)}) it would otherwise inflate this node's own
|
||||
* width/height beyond the icon's, growing the icon's on-screen footprint the moment a notification
|
||||
* badge appears. The dot still overlaps the icon's corner visually (via {@link #DOT_MARGIN}'s negative
|
||||
* inset) without ever being allowed to grow the space this component reports to its own parent.
|
||||
*/
|
||||
@Override
|
||||
protected double computeMinWidth(double height) {
|
||||
return icon.minWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMinHeight(double width) {
|
||||
return icon.minHeight(width);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height) {
|
||||
return icon.prefWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width) {
|
||||
return icon.prefHeight(width);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxWidth(double height) {
|
||||
return icon.maxWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxHeight(double width) {
|
||||
return icon.maxHeight(width);
|
||||
}
|
||||
|
||||
/** Convenience building the icon too, sized the theme-safe way (see {@link FxUtils#icon}). */
|
||||
public static IconBadge of(Ikon glyph, EIconSize size) {
|
||||
return new IconBadge(FxUtils.icon(glyph, size));
|
||||
}
|
||||
|
||||
/** The wrapped icon, for callers that need to keep mutating it (tooltip, cursor, later glyph swap, ...). */
|
||||
public FontIcon getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public BooleanProperty badgeVisibleProperty() {
|
||||
return badgeVisible;
|
||||
}
|
||||
|
||||
public boolean isBadgeVisible() {
|
||||
return badgeVisible.get();
|
||||
}
|
||||
|
||||
public void setBadgeVisible(boolean visible) {
|
||||
badgeVisible.set(visible);
|
||||
}
|
||||
|
||||
public ObjectProperty<ENotificationBadgeColor> badgeColorProperty() {
|
||||
return badgeColor;
|
||||
}
|
||||
|
||||
public ENotificationBadgeColor getBadgeColor() {
|
||||
return badgeColor.get();
|
||||
}
|
||||
|
||||
public void setBadgeColor(ENotificationBadgeColor color) {
|
||||
badgeColor.set(Objects.requireNonNull(color, "color"));
|
||||
}
|
||||
|
||||
private void applyColor(@Nullable ENotificationBadgeColor previous, ENotificationBadgeColor updated) {
|
||||
if (previous != null) {
|
||||
dot.getStyleClass().remove(previous.styleClass());
|
||||
}
|
||||
dot.getStyleClass().add(updated.styleClass());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package org.icroco.pholio.ui.shell;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.icroco.pholio.ui.common.Disposable;
|
||||
import org.icroco.pholio.ui.common.SubscriptionScope;
|
||||
import org.icroco.pholio.ui.common.UiComponent;
|
||||
import org.icroco.pholio.infra.i18n.I18nService;
|
||||
import org.icroco.pholio.ui.control.IconBadge;
|
||||
import org.icroco.pholio.ui.notification.NotificationsListPane;
|
||||
import org.icroco.pholio.ui.notification.NotificationsManager;
|
||||
import org.kordamp.ikonli.feather.Feather;
|
||||
@@ -15,26 +12,22 @@ import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
/**
|
||||
* The permanent, always-visible face of the persisted notification log, next to {@link TaskMonitorWidget}
|
||||
* on the status bar — same shape, mirrored: an icon, a count label, a click toggles
|
||||
* {@code notificationsListPane} open on {@link StatusDrawer}'s left side (right already hosts the task
|
||||
* list; see that class's own javadoc).
|
||||
* on the status bar — a bell icon whose {@link IconBadge} dot lights up while there's at least one
|
||||
* notification (no count, just presence — the full list, and the actual counts, live in
|
||||
* {@code notificationsListPane} itself), a click toggles that pane open on {@link StatusDrawer}'s left side
|
||||
* (right already hosts the task list; see that class's own javadoc).
|
||||
*/
|
||||
@UiComponent
|
||||
public class NotificationsWidget extends HBox implements Disposable {
|
||||
|
||||
private final I18nService i18n;
|
||||
private final StatusDrawer statusDrawer;
|
||||
private final NotificationsListPane notificationsListPane;
|
||||
|
||||
private final SubscriptionScope scope = new SubscriptionScope();
|
||||
private final Label label = new Label();
|
||||
private final FontIcon icon = FontIcon.of(Feather.BELL, 13);
|
||||
private final IconBadge icon = new IconBadge(FontIcon.of(Feather.BELL, 13));
|
||||
|
||||
public NotificationsWidget(I18nService i18n,
|
||||
NotificationsManager notificationsManager,
|
||||
public NotificationsWidget(NotificationsManager notificationsManager,
|
||||
StatusDrawer statusDrawer,
|
||||
NotificationsListPane notificationsListPane) {
|
||||
this.i18n = i18n;
|
||||
this.statusDrawer = statusDrawer;
|
||||
this.notificationsListPane = notificationsListPane;
|
||||
|
||||
@@ -42,31 +35,18 @@ public class NotificationsWidget extends HBox implements Disposable {
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
setSpacing(6);
|
||||
|
||||
label.getStyleClass().addAll(Styles.TEXT_MUTED, Styles.TEXT_SMALL);
|
||||
|
||||
getChildren().addAll(icon, label);
|
||||
getChildren().add(icon);
|
||||
setOnMouseClicked(_ -> {
|
||||
statusDrawer.addLeft(notificationsListPane);
|
||||
statusDrawer.toggle();
|
||||
});
|
||||
|
||||
scope.add(notificationsManager.countBinding().subscribe(this::update));
|
||||
}
|
||||
|
||||
private void update(Number count) {
|
||||
label.textProperty().unbind();
|
||||
int value = count.intValue();
|
||||
if (value == 0) {
|
||||
label.textProperty().bind(i18n.binding("status.notifications.none"));
|
||||
} else {
|
||||
label.textProperty().bind(i18n.binding("status.notifications.count", value));
|
||||
}
|
||||
icon.badgeVisibleProperty().bind(notificationsManager.countBinding().greaterThan(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
scope.close();
|
||||
label.textProperty().unbind();
|
||||
icon.badgeVisibleProperty().unbind();
|
||||
statusDrawer.removeLeft(notificationsListPane);
|
||||
notificationsListPane.dispose();
|
||||
setOnMouseClicked(null);
|
||||
|
||||
@@ -620,6 +620,35 @@
|
||||
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.6), 2, 0.6, 0, 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* IconBadge's small notification dot — a plain colored circle, no count, pinned to the icon's top-right
|
||||
* corner (see IconBadge's own class javadoc for the overlap rationale). -color-bg-default draws a thin
|
||||
* "cutout" ring around the dot so it stays legible even when the dot's color is close to whatever's
|
||||
* underneath — same purpose .thumbnail-overlay-icon's dropshadow serves for its own badges, just via a
|
||||
* ring instead since a flat dot has no shape detail a shadow would help read.
|
||||
*/
|
||||
.icon-badge-dot {
|
||||
-fx-stroke: -color-bg-default;
|
||||
-fx-stroke-width: 1.5px;
|
||||
-fx-fill: -color-accent-emphasis;
|
||||
}
|
||||
|
||||
.icon-badge-dot.accent {
|
||||
-fx-fill: -color-accent-emphasis;
|
||||
}
|
||||
|
||||
.icon-badge-dot.success {
|
||||
-fx-fill: -color-success-emphasis;
|
||||
}
|
||||
|
||||
.icon-badge-dot.warning {
|
||||
-fx-fill: -color-warning-emphasis;
|
||||
}
|
||||
|
||||
.icon-badge-dot.danger {
|
||||
-fx-fill: -color-danger-emphasis;
|
||||
}
|
||||
|
||||
/*
|
||||
* The grid's own per-thumbnail multi-select checkbox — a tool in its own right, entirely independent of
|
||||
* single-click "select for preview" (:selected, the card's accent border). Hidden until the pointer is over
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.icroco.pholio.ui.control;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.scene.shape.Circle;
|
||||
import org.assertj.core.api.SoftAssertions;
|
||||
import org.icroco.pholio.ui.FxTestToolkit;
|
||||
import org.icroco.pholio.ui.shell.EIconSize;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.kordamp.ikonli.feather.Feather;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.onFxThread;
|
||||
import static org.icroco.pholio.ui.FxTestToolkit.runOnFxThread;
|
||||
|
||||
class IconBadgeTest {
|
||||
|
||||
@Test
|
||||
void defaultsToHiddenAccent() {
|
||||
FxTestToolkit.requireToolkit();
|
||||
IconBadge badge = onFxThread(() -> IconBadge.of(Feather.BELL, EIconSize.NORMAL));
|
||||
|
||||
SoftAssertions softly = new SoftAssertions();
|
||||
softly.assertThat(badge.isBadgeVisible()).isFalse();
|
||||
softly.assertThat(badge.getBadgeColor()).isEqualTo(ENotificationBadgeColor.ACCENT);
|
||||
softly.assertAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingColorSwapsStyleClassRatherThanAccumulating() {
|
||||
FxTestToolkit.requireToolkit();
|
||||
IconBadge badge = onFxThread(() -> IconBadge.of(Feather.BELL, EIconSize.NORMAL));
|
||||
|
||||
runOnFxThread(() -> badge.setBadgeColor(ENotificationBadgeColor.DANGER));
|
||||
|
||||
Circle dot = (Circle) badge.getChildren().get(1);
|
||||
SoftAssertions softly = new SoftAssertions();
|
||||
softly.assertThat(dot.getStyleClass()).contains(Styles.DANGER);
|
||||
softly.assertThat(dot.getStyleClass()).doesNotContain(Styles.ACCENT);
|
||||
softly.assertAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void badgeVisibleBindsReactively() {
|
||||
FxTestToolkit.requireToolkit();
|
||||
IconBadge badge = onFxThread(() -> IconBadge.of(Feather.BELL, EIconSize.NORMAL));
|
||||
BooleanProperty hasNotifications = new SimpleBooleanProperty(false);
|
||||
|
||||
runOnFxThread(() -> badge.badgeVisibleProperty().bind(hasNotifications));
|
||||
runOnFxThread(() -> hasNotifications.set(true));
|
||||
|
||||
assertThat(badge.isBadgeVisible()).isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user