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:
2026-09-20 17:50:48 -04:00
co-authored by Claude Sonnet 5
parent 9e86fc3bfb
commit 77522de02d
5 changed files with 265 additions and 30 deletions
@@ -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);
+29
View File
@@ -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();
}
}