diff --git a/src/main/java/com/example/workutils/system/ui/MainWindow.java b/src/main/java/com/example/workutils/system/ui/MainWindow.java index 690e381..2ae146a 100644 --- a/src/main/java/com/example/workutils/system/ui/MainWindow.java +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -40,7 +40,7 @@ public class MainWindow { private EmbeddedEntry currentEmbedded; private HBox pluginToolbar; - private Label toolbarIcon; + private FontIcon toolbarIcon; private Label toolbarTitle; public MainWindow(List navItems, WorkUtilsProperties properties) { @@ -120,8 +120,7 @@ public class MainWindow { pluginToolbar.setVisible(false); pluginToolbar.setManaged(false); - toolbarIcon = new Label(); - toolbarIcon.setFont(Theme.body()); + toolbarIcon = IconFactory.create(AppIcon.APP, 18); toolbarTitle = new Label(); toolbarTitle.setFont(Theme.body()); pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle); @@ -141,8 +140,8 @@ public class MainWindow { showEmbeddedFor(item); } - private void showPluginToolbar(String icon, String title) { - toolbarIcon.setText(icon); + private void showPluginToolbar(AppIcon icon, String title) { + toolbarIcon.setIconCode(icon.glyph()); toolbarTitle.setText(title); pluginToolbar.setVisible(true); pluginToolbar.setManaged(true); @@ -191,9 +190,9 @@ public class MainWindow { } } - public void embedPlugin(String pluginId, String icon, String name, Parent content) { + public void embedPlugin(String pluginId, String ignoredIcon, String name, Parent content) { embeddedCache.remove(pluginId); - EmbeddedEntry entry = new EmbeddedEntry(content, icon, name); + EmbeddedEntry entry = new EmbeddedEntry(content, NavItem.iconForPlugin(pluginId), name); embeddedCache.put(pluginId, entry); switchToEmbedded(pluginId, entry); } @@ -242,7 +241,7 @@ public class MainWindow { wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA); wrapper.setCenter(content); contentArea.getChildren().setAll(wrapper); - showPluginToolbar(icon, title); + showPluginToolbar(AppIcon.APP, title); } public void refreshTheme() { @@ -251,11 +250,11 @@ public class MainWindow { private static final class EmbeddedEntry { private final Parent content; - private final String icon; + private final AppIcon icon; private final String title; private BorderPane wrapper; - private EmbeddedEntry(Parent content, String icon, String title) { + private EmbeddedEntry(Parent content, AppIcon icon, String title) { this.content = content; this.icon = icon; this.title = title; @@ -303,6 +302,7 @@ public class MainWindow { case "git-review" -> AppIcon.GIT_REVIEW; case "mybatisexport" -> AppIcon.SQL_EXPORT; case "mybatisgenerator" -> AppIcon.MYBATIS_GENERATOR; + case "mobile-ui-auto" -> AppIcon.APP; case "__settings__" -> AppIcon.SETTINGS; default -> AppIcon.APP; }; diff --git a/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java b/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java index 603fee3..764bcba 100644 --- a/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java +++ b/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java @@ -22,6 +22,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -37,9 +38,8 @@ public final class NavigationRail extends VBox { private final ReadOnlyBooleanWrapper expanded = new ReadOnlyBooleanWrapper(this, "expanded", true); private final Consumer navigator; private final ToggleGroup selection = new ToggleGroup(); - private final Map itemsByPluginId = new LinkedHashMap<>(); - private final Map buttonsByPluginId = new LinkedHashMap<>(); - private final Map tooltipsByPluginId = new LinkedHashMap<>(); + private final List itemViews = new ArrayList<>(); + private final Map viewsByPluginId = new LinkedHashMap<>(); private final VBox itemBox = new VBox(); private final Label brandLabel = new Label("WorkUtils"); private final Button collapseButton = new Button(); @@ -83,12 +83,13 @@ public final class NavigationRail extends VBox { public void selectPlugin(String pluginId) { requireFxThread(); - ToggleButton button = buttonsByPluginId.get(pluginId); - MainWindow.NavItem item = itemsByPluginId.get(pluginId); - if (button == null || item == null) { + if (pluginId == null) { return; } - activate(item, button); + ItemView view = viewsByPluginId.get(pluginId); + if (view != null) { + activate(view.item(), view.button()); + } } public String selectedPluginId() { @@ -100,21 +101,26 @@ public final class NavigationRail extends VBox { Objects.requireNonNull(items, "items must not be null"); selection.selectToggle(null); + for (ItemView view : itemViews) { + view.button().setOnAction(null); + view.button().setTooltip(null); + view.button().setToggleGroup(null); + } itemBox.getChildren().clear(); - itemsByPluginId.clear(); - buttonsByPluginId.clear(); - tooltipsByPluginId.clear(); + itemViews.clear(); + viewsByPluginId.clear(); selectedItem = null; selectedPluginId = null; - for (MainWindow.NavItem item : items) { - addItem(Objects.requireNonNull(item, "navigation item must not be null")); + for (int index = 0; index < items.size(); index++) { + addItem(Objects.requireNonNull(items.get(index), "navigation item must not be null"), index); } updateItemAppearance(); } Tooltip tooltipFor(String pluginId) { - return tooltipsByPluginId.get(pluginId); + ItemView view = pluginId == null ? null : viewsByPluginId.get(pluginId); + return view == null ? null : view.tooltip(); } private void buildHeader() { @@ -137,7 +143,7 @@ public final class NavigationRail extends VBox { getChildren().add(header); } - private void addItem(MainWindow.NavItem item) { + private void addItem(MainWindow.NavItem item, int index) { ToggleButton button = new ToggleButton(item.name, IconFactory.createButtonIcon(item.icon, item.name)); button.setToggleGroup(selection); button.setContentDisplay(ContentDisplay.LEFT); @@ -149,21 +155,24 @@ public final class NavigationRail extends VBox { button.getStyleClass().add(Theme.CSS_NAV_CELL); if (item.pluginId != null) { button.setId("nav-" + item.pluginId); + } else { + button.setId("nav-item-" + index); } Tooltip tooltip = new Tooltip(item.name); button.setOnAction(event -> activate(item, button)); - itemsByPluginId.put(item.pluginId, item); - buttonsByPluginId.put(item.pluginId, button); - tooltipsByPluginId.put(item.pluginId, tooltip); + ItemView view = new ItemView(item, button, tooltip); + itemViews.add(view); + if (item.pluginId != null) { + viewsByPluginId.put(item.pluginId, view); + } itemBox.getChildren().add(button); } private void activate(MainWindow.NavItem item, ToggleButton button) { selection.selectToggle(button); - buttonsByPluginId.values().forEach(candidate -> - candidate.getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); + itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); if (!button.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) { button.getStyleClass().add(Theme.CSS_NAV_CELL_ACTIVE); } @@ -194,15 +203,18 @@ public final class NavigationRail extends VBox { } private void updateItemAppearance() { - for (Map.Entry entry : buttonsByPluginId.entrySet()) { - MainWindow.NavItem item = itemsByPluginId.get(entry.getKey()); - ToggleButton button = entry.getValue(); + for (ItemView view : itemViews) { + MainWindow.NavItem item = view.item(); + ToggleButton button = view.button(); button.setText(isExpanded() ? item.name : null); - button.setTooltip(isExpanded() ? null : tooltipsByPluginId.get(entry.getKey())); + button.setTooltip(isExpanded() ? null : view.tooltip()); button.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); } } + private record ItemView(MainWindow.NavItem item, ToggleButton button, Tooltip tooltip) { + } + private static void requireFxThread() { if (!Platform.isFxApplicationThread()) { throw new IllegalStateException("NavigationRail must be accessed on the JavaFX Application Thread"); diff --git a/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java b/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java index 362867b..7f436ca 100644 --- a/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java +++ b/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java @@ -4,17 +4,25 @@ import com.example.workutils.system.ui.DesignTokens; import com.example.workutils.system.ui.MainWindow; import com.example.workutils.system.ui.Theme; import com.example.workutils.system.ui.icon.AppIcon; +import com.example.workutils.system.config.WorkUtilsProperties; import javafx.css.PseudoClass; import javafx.scene.control.ToggleButton; import javafx.scene.control.Tooltip; +import javafx.scene.layout.StackPane; import org.junit.jupiter.api.Test; +import org.kordamp.ikonli.javafx.FontIcon; +import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static com.example.workutils.system.ui.FxTestSupport.fx; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,6 +37,22 @@ class NavigationRailTest { }, "api-doc"); + @Test + void pluginToolbarUsesFeatherSemanticsInsteadOfLegacyTextIcons() throws Exception { + fx(() -> { + MainWindow window = new MainWindow(List.of(), new WorkUtilsProperties()); + + window.embedPlugin("api-doc", "legacy-api-icon", "API", new StackPane()); + assertEquals(AppIcon.API_DOC.glyph(), toolbarIcon(window).getIconCode()); + + window.embedPlugin("mobile-ui-auto", "legacy-mobile-icon", "Mobile", new StackPane()); + assertEquals(AppIcon.APP.glyph(), toolbarIcon(window).getIconCode()); + + window.embedPlugin("third-party", "legacy-third-party-icon", "Other", new StackPane()); + assertEquals(AppIcon.APP.glyph(), toolbarIcon(window).getIconCode()); + }); + } + @Test void collapsePreservesSelectionAndDoesNotRouteAgain() throws Exception { fx(() -> { @@ -67,6 +91,79 @@ class NavigationRailTest { }); } + @Test + void itemsWithoutPluginIdsKeepIndependentCollapseAndActiveState() throws Exception { + fx(() -> { + MainWindow.NavItem first = new MainWindow.NavItem( + AppIcon.DIFF, "First", "First item", () -> { + }); + MainWindow.NavItem second = new MainWindow.NavItem( + AppIcon.SCANNER, "Second", "Second item", () -> { + }); + List routed = new ArrayList<>(); + NavigationRail rail = new NavigationRail(List.of(first, second), routed::add); + + rail.setExpanded(false); + ToggleButton firstButton = (ToggleButton) rail.lookup("#nav-item-0"); + ToggleButton secondButton = (ToggleButton) rail.lookup("#nav-item-1"); + + assertNotNull(firstButton); + assertNotNull(secondButton); + assertEquals("First", firstButton.getTooltip().getText()); + assertEquals("Second", secondButton.getTooltip().getText()); + + firstButton.fire(); + secondButton.fire(); + + assertEquals(List.of(first, second), routed); + assertTrue(secondButton.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)); + assertFalse(firstButton.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)); + }); + } + + @Test + void nullAndUnknownPluginIdsDoNotSelectOrRoute() throws Exception { + fx(() -> { + AtomicInteger routed = new AtomicInteger(); + MainWindow.NavItem withoutId = new MainWindow.NavItem( + AppIcon.APP, "No ID", "No ID item", () -> { + }); + NavigationRail rail = new NavigationRail(List.of(withoutId, API_DOC), + item -> routed.incrementAndGet()); + + rail.selectPlugin(null); + rail.selectPlugin("unknown-plugin"); + + assertEquals(0, routed.get()); + assertNull(rail.selectedPluginId()); + }); + } + + @Test + void refreshDetachesOldButtonsFromSelectionAndRouting() throws Exception { + fx(() -> { + AtomicInteger routed = new AtomicInteger(); + MainWindow.NavItem first = new MainWindow.NavItem( + AppIcon.DIFF, "First", "First item", () -> { + }, "first"); + MainWindow.NavItem second = new MainWindow.NavItem( + AppIcon.SCANNER, "Second", "Second item", () -> { + }, "second"); + NavigationRail rail = new NavigationRail(List.of(first), item -> routed.incrementAndGet()); + ToggleButton oldButton = (ToggleButton) rail.lookup("#nav-first"); + oldButton.fire(); + + rail.setItems(List.of(second)); + ToggleButton newButton = (ToggleButton) rail.lookup("#nav-second"); + + assertNull(oldButton.getToggleGroup()); + assertNull(oldButton.getOnAction()); + assertEquals(1, newButton.getToggleGroup().getToggles().size()); + oldButton.fire(); + assertEquals(1, routed.get()); + }); + } + @Test void collapsedRailUsesExactFixedWidthAndCollapsedPseudoClass() throws Exception { fx(() -> { @@ -100,4 +197,10 @@ class NavigationRailTest { assertSame(tooltip, apiDocButton.getTooltip()); }); } + + private static FontIcon toolbarIcon(MainWindow window) throws ReflectiveOperationException { + Field field = MainWindow.class.getDeclaredField("toolbarIcon"); + field.setAccessible(true); + return assertInstanceOf(FontIcon.class, field.get(window)); + } }