修复导航语义图标与刷新状态

This commit is contained in:
张家训
2026-08-01 14:41:24 +08:00
parent 9883ca0950
commit 0a24a9b52b
3 changed files with 148 additions and 33 deletions
@@ -40,7 +40,7 @@ public class MainWindow {
private EmbeddedEntry currentEmbedded; private EmbeddedEntry currentEmbedded;
private HBox pluginToolbar; private HBox pluginToolbar;
private Label toolbarIcon; private FontIcon toolbarIcon;
private Label toolbarTitle; private Label toolbarTitle;
public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties) { public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties) {
@@ -120,8 +120,7 @@ public class MainWindow {
pluginToolbar.setVisible(false); pluginToolbar.setVisible(false);
pluginToolbar.setManaged(false); pluginToolbar.setManaged(false);
toolbarIcon = new Label(); toolbarIcon = IconFactory.create(AppIcon.APP, 18);
toolbarIcon.setFont(Theme.body());
toolbarTitle = new Label(); toolbarTitle = new Label();
toolbarTitle.setFont(Theme.body()); toolbarTitle.setFont(Theme.body());
pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle); pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle);
@@ -141,8 +140,8 @@ public class MainWindow {
showEmbeddedFor(item); showEmbeddedFor(item);
} }
private void showPluginToolbar(String icon, String title) { private void showPluginToolbar(AppIcon icon, String title) {
toolbarIcon.setText(icon); toolbarIcon.setIconCode(icon.glyph());
toolbarTitle.setText(title); toolbarTitle.setText(title);
pluginToolbar.setVisible(true); pluginToolbar.setVisible(true);
pluginToolbar.setManaged(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); embeddedCache.remove(pluginId);
EmbeddedEntry entry = new EmbeddedEntry(content, icon, name); EmbeddedEntry entry = new EmbeddedEntry(content, NavItem.iconForPlugin(pluginId), name);
embeddedCache.put(pluginId, entry); embeddedCache.put(pluginId, entry);
switchToEmbedded(pluginId, entry); switchToEmbedded(pluginId, entry);
} }
@@ -242,7 +241,7 @@ public class MainWindow {
wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA); wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA);
wrapper.setCenter(content); wrapper.setCenter(content);
contentArea.getChildren().setAll(wrapper); contentArea.getChildren().setAll(wrapper);
showPluginToolbar(icon, title); showPluginToolbar(AppIcon.APP, title);
} }
public void refreshTheme() { public void refreshTheme() {
@@ -251,11 +250,11 @@ public class MainWindow {
private static final class EmbeddedEntry { private static final class EmbeddedEntry {
private final Parent content; private final Parent content;
private final String icon; private final AppIcon icon;
private final String title; private final String title;
private BorderPane wrapper; private BorderPane wrapper;
private EmbeddedEntry(Parent content, String icon, String title) { private EmbeddedEntry(Parent content, AppIcon icon, String title) {
this.content = content; this.content = content;
this.icon = icon; this.icon = icon;
this.title = title; this.title = title;
@@ -303,6 +302,7 @@ public class MainWindow {
case "git-review" -> AppIcon.GIT_REVIEW; case "git-review" -> AppIcon.GIT_REVIEW;
case "mybatisexport" -> AppIcon.SQL_EXPORT; case "mybatisexport" -> AppIcon.SQL_EXPORT;
case "mybatisgenerator" -> AppIcon.MYBATIS_GENERATOR; case "mybatisgenerator" -> AppIcon.MYBATIS_GENERATOR;
case "mobile-ui-auto" -> AppIcon.APP;
case "__settings__" -> AppIcon.SETTINGS; case "__settings__" -> AppIcon.SETTINGS;
default -> AppIcon.APP; default -> AppIcon.APP;
}; };
@@ -22,6 +22,7 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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 ReadOnlyBooleanWrapper expanded = new ReadOnlyBooleanWrapper(this, "expanded", true);
private final Consumer<MainWindow.NavItem> navigator; private final Consumer<MainWindow.NavItem> navigator;
private final ToggleGroup selection = new ToggleGroup(); private final ToggleGroup selection = new ToggleGroup();
private final Map<String, MainWindow.NavItem> itemsByPluginId = new LinkedHashMap<>(); private final List<ItemView> itemViews = new ArrayList<>();
private final Map<String, ToggleButton> buttonsByPluginId = new LinkedHashMap<>(); private final Map<String, ItemView> viewsByPluginId = new LinkedHashMap<>();
private final Map<String, Tooltip> tooltipsByPluginId = new LinkedHashMap<>();
private final VBox itemBox = new VBox(); private final VBox itemBox = new VBox();
private final Label brandLabel = new Label("WorkUtils"); private final Label brandLabel = new Label("WorkUtils");
private final Button collapseButton = new Button(); private final Button collapseButton = new Button();
@@ -83,12 +83,13 @@ public final class NavigationRail extends VBox {
public void selectPlugin(String pluginId) { public void selectPlugin(String pluginId) {
requireFxThread(); requireFxThread();
ToggleButton button = buttonsByPluginId.get(pluginId); if (pluginId == null) {
MainWindow.NavItem item = itemsByPluginId.get(pluginId);
if (button == null || item == null) {
return; return;
} }
activate(item, button); ItemView view = viewsByPluginId.get(pluginId);
if (view != null) {
activate(view.item(), view.button());
}
} }
public String selectedPluginId() { public String selectedPluginId() {
@@ -100,21 +101,26 @@ public final class NavigationRail extends VBox {
Objects.requireNonNull(items, "items must not be null"); Objects.requireNonNull(items, "items must not be null");
selection.selectToggle(null); selection.selectToggle(null);
for (ItemView view : itemViews) {
view.button().setOnAction(null);
view.button().setTooltip(null);
view.button().setToggleGroup(null);
}
itemBox.getChildren().clear(); itemBox.getChildren().clear();
itemsByPluginId.clear(); itemViews.clear();
buttonsByPluginId.clear(); viewsByPluginId.clear();
tooltipsByPluginId.clear();
selectedItem = null; selectedItem = null;
selectedPluginId = null; selectedPluginId = null;
for (MainWindow.NavItem item : items) { for (int index = 0; index < items.size(); index++) {
addItem(Objects.requireNonNull(item, "navigation item must not be null")); addItem(Objects.requireNonNull(items.get(index), "navigation item must not be null"), index);
} }
updateItemAppearance(); updateItemAppearance();
} }
Tooltip tooltipFor(String pluginId) { 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() { private void buildHeader() {
@@ -137,7 +143,7 @@ public final class NavigationRail extends VBox {
getChildren().add(header); 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)); ToggleButton button = new ToggleButton(item.name, IconFactory.createButtonIcon(item.icon, item.name));
button.setToggleGroup(selection); button.setToggleGroup(selection);
button.setContentDisplay(ContentDisplay.LEFT); button.setContentDisplay(ContentDisplay.LEFT);
@@ -149,21 +155,24 @@ public final class NavigationRail extends VBox {
button.getStyleClass().add(Theme.CSS_NAV_CELL); button.getStyleClass().add(Theme.CSS_NAV_CELL);
if (item.pluginId != null) { if (item.pluginId != null) {
button.setId("nav-" + item.pluginId); button.setId("nav-" + item.pluginId);
} else {
button.setId("nav-item-" + index);
} }
Tooltip tooltip = new Tooltip(item.name); Tooltip tooltip = new Tooltip(item.name);
button.setOnAction(event -> activate(item, button)); button.setOnAction(event -> activate(item, button));
itemsByPluginId.put(item.pluginId, item); ItemView view = new ItemView(item, button, tooltip);
buttonsByPluginId.put(item.pluginId, button); itemViews.add(view);
tooltipsByPluginId.put(item.pluginId, tooltip); if (item.pluginId != null) {
viewsByPluginId.put(item.pluginId, view);
}
itemBox.getChildren().add(button); itemBox.getChildren().add(button);
} }
private void activate(MainWindow.NavItem item, ToggleButton button) { private void activate(MainWindow.NavItem item, ToggleButton button) {
selection.selectToggle(button); selection.selectToggle(button);
buttonsByPluginId.values().forEach(candidate -> itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE));
candidate.getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE));
if (!button.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) { if (!button.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) {
button.getStyleClass().add(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() { private void updateItemAppearance() {
for (Map.Entry<String, ToggleButton> entry : buttonsByPluginId.entrySet()) { for (ItemView view : itemViews) {
MainWindow.NavItem item = itemsByPluginId.get(entry.getKey()); MainWindow.NavItem item = view.item();
ToggleButton button = entry.getValue(); ToggleButton button = view.button();
button.setText(isExpanded() ? item.name : null); 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); button.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER);
} }
} }
private record ItemView(MainWindow.NavItem item, ToggleButton button, Tooltip tooltip) {
}
private static void requireFxThread() { private static void requireFxThread() {
if (!Platform.isFxApplicationThread()) { if (!Platform.isFxApplicationThread()) {
throw new IllegalStateException("NavigationRail must be accessed on the JavaFX Application Thread"); throw new IllegalStateException("NavigationRail must be accessed on the JavaFX Application Thread");
@@ -4,17 +4,25 @@ import com.example.workutils.system.ui.DesignTokens;
import com.example.workutils.system.ui.MainWindow; import com.example.workutils.system.ui.MainWindow;
import com.example.workutils.system.ui.Theme; import com.example.workutils.system.ui.Theme;
import com.example.workutils.system.ui.icon.AppIcon; import com.example.workutils.system.ui.icon.AppIcon;
import com.example.workutils.system.config.WorkUtilsProperties;
import javafx.css.PseudoClass; import javafx.css.PseudoClass;
import javafx.scene.control.ToggleButton; import javafx.scene.control.ToggleButton;
import javafx.scene.control.Tooltip; import javafx.scene.control.Tooltip;
import javafx.scene.layout.StackPane;
import org.junit.jupiter.api.Test; 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.List;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static com.example.workutils.system.ui.FxTestSupport.fx; import static com.example.workutils.system.ui.FxTestSupport.fx;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -29,6 +37,22 @@ class NavigationRailTest {
}, },
"api-doc"); "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 @Test
void collapsePreservesSelectionAndDoesNotRouteAgain() throws Exception { void collapsePreservesSelectionAndDoesNotRouteAgain() throws Exception {
fx(() -> { 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<MainWindow.NavItem> 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 @Test
void collapsedRailUsesExactFixedWidthAndCollapsedPseudoClass() throws Exception { void collapsedRailUsesExactFixedWidthAndCollapsedPseudoClass() throws Exception {
fx(() -> { fx(() -> {
@@ -100,4 +197,10 @@ class NavigationRailTest {
assertSame(tooltip, apiDocButton.getTooltip()); 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));
}
} }