From 4c873b3f4f0783e0e53a61474bd4817d57b5df14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Sat, 1 Aug 2026 20:54:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AF=BC=E8=88=AA=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=B7=A5=E5=85=B7=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workutils/system/ui/MainWindow.java | 30 ++------ .../system/ui/shell/NavigationRail.java | 51 +++++++++++++- .../system/ui/MainWindowArchitectureTest.java | 17 ++--- .../ui/NativeUiVisualAcceptanceFixture.java | 31 +++------ .../system/ui/shell/NavigationRailTest.java | 68 ++++++++++--------- 5 files changed, 111 insertions(+), 86 deletions(-) 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 c28180b..18da749 100644 --- a/src/main/java/com/example/workutils/system/ui/MainWindow.java +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -8,10 +8,7 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Parent; import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.ContentDisplay; import javafx.scene.control.Label; -import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; @@ -88,9 +85,9 @@ public class MainWindow { stage.setOnCloseRequest(event -> embeddedCache.clear()); BorderPane root = new BorderPane(); - root.setTop(buildUnifiedHeader(openSettings)); + root.setTop(buildUnifiedHeader()); - navigationRail = new NavigationRail(navItems, this::navigateTo); + navigationRail = new NavigationRail(navItems, this::navigateTo, openSettings); contentArea = new StackPane(); contentArea.getStyleClass().add(Theme.CSS_CONTENT_AREA); HBox.setHgrow(contentArea, Priority.ALWAYS); @@ -104,7 +101,7 @@ public class MainWindow { showWelcomePage(); } - private HBox buildUnifiedHeader(Runnable openSettings) { + private HBox buildUnifiedHeader() { HBox bar = new HBox(Theme.S1); bar.setId("app-header"); bar.setAlignment(Pos.CENTER_LEFT); @@ -114,13 +111,6 @@ public class MainWindow { bar.setMaxHeight(DesignTokens.HEADER_HEIGHT); bar.getStyleClass().add(Theme.CSS_HEADER_BAR); - Button menuButton = new Button(null, IconFactory.createButtonIcon(AppIcon.MENU, "切换导航栏")); - menuButton.setId("menu-button"); - menuButton.setTooltip(new Tooltip("切换导航栏")); - menuButton.setAccessibleText("切换导航栏"); - menuButton.setFocusTraversable(true); - menuButton.setOnAction(event -> navigationRail.toggle()); - FontIcon brandIcon = IconFactory.create(AppIcon.APP, 18); StackPane brandIconContainer = new StackPane(brandIcon); brandIconContainer.setId("app-brand-icon-container"); @@ -148,17 +138,7 @@ public class MainWindow { Region spacerRight = new Region(); HBox.setHgrow(spacerRight, Priority.ALWAYS); - Button settingsButton = new Button( - "系统设置", IconFactory.createButtonIcon(AppIcon.SETTINGS, "系统设置")); - settingsButton.setId("settings-button"); - settingsButton.setTooltip(new Tooltip("系统设置")); - settingsButton.setAccessibleText("系统设置"); - settingsButton.setContentDisplay(ContentDisplay.LEFT); - settingsButton.setFocusTraversable(true); - settingsButton.setOnAction(event -> openSettings.run()); - - bar.getChildren().addAll( - menuButton, brand, toolbarChevron, toolbarTitle, spacerRight, settingsButton); + bar.getChildren().addAll(brand, toolbarChevron, toolbarTitle, spacerRight); return bar; } @@ -237,7 +217,7 @@ public class MainWindow { private void switchToEmbedded(String pluginId, EmbeddedEntry entry) { if ("__settings__".equals(pluginId)) { - navigationRail.clearSelection(); + navigationRail.selectSettings(); } if (currentEmbedded != null && currentEmbedded != entry) { contentArea.getChildren().remove(currentEmbedded.wrapper); 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 f89be0d..cb79c14 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 @@ -39,6 +39,8 @@ public final class NavigationRail extends VBox { private final List itemViews = new ArrayList<>(); private final Map viewsByPluginId = new LinkedHashMap<>(); private final VBox itemBox = new VBox(); + private final VBox utilityBox = new VBox(); + private final ToggleButton settingsButton = new ToggleButton(); private final Button collapseButton = new Button(); private MainWindow.NavItem selectedItem; @@ -46,14 +48,26 @@ public final class NavigationRail extends VBox { public NavigationRail(List items, Consumer navigator) { + this(items, navigator, () -> { }); + } + + public NavigationRail(List items, + Consumer navigator, + Runnable openSettings) { requireFxThread(); this.navigator = Objects.requireNonNull(navigator, "navigator must not be null"); + Objects.requireNonNull(openSettings, "openSettings must not be null"); getStyleClass().addAll(Theme.CSS_NAV_BAR, "navigation-rail"); itemBox.getStyleClass().add("navigation-rail-items"); VBox.setVgrow(itemBox, Priority.ALWAYS); + buildSettingsEntry(openSettings); buildBottomToggle(); + utilityBox.setId("navigation-rail-tools"); + utilityBox.getStyleClass().add("navigation-rail-tools"); + utilityBox.getChildren().addAll(settingsButton, collapseButton); + Separator separator = new Separator(); separator.setId("navigation-rail-separator"); separator.getStyleClass().add(Theme.CSS_NAV_SEP); @@ -62,7 +76,7 @@ public final class NavigationRail extends VBox { separator.setPrefHeight(1); separator.setMaxHeight(1); separator.setFocusTraversable(false); - getChildren().addAll(itemBox, separator, collapseButton); + getChildren().addAll(itemBox, separator, utilityBox); expanded.addListener((observable, oldValue, newValue) -> updateAppearance()); setItems(items); @@ -106,6 +120,18 @@ public final class NavigationRail extends VBox { requireFxThread(); selection.selectToggle(null); itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); + settingsButton.getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE); + selectedItem = null; + selectedPluginId = null; + } + + public void selectSettings() { + requireFxThread(); + selection.selectToggle(settingsButton); + itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); + if (!settingsButton.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) { + settingsButton.getStyleClass().add(Theme.CSS_NAV_CELL_ACTIVE); + } selectedItem = null; selectedPluginId = null; } @@ -137,6 +163,24 @@ public final class NavigationRail extends VBox { return view == null ? null : view.tooltip(); } + private void buildSettingsEntry(Runnable openSettings) { + settingsButton.setId("navigation-rail-settings"); + settingsButton.getStyleClass().addAll(Theme.CSS_NAV_CELL, "navigation-rail-settings"); + settingsButton.setToggleGroup(selection); + settingsButton.setContentDisplay(ContentDisplay.LEFT); + settingsButton.setGraphicTextGap(Theme.S3); + settingsButton.setMinHeight(DesignTokens.ROW_HEIGHT); + settingsButton.setPrefHeight(DesignTokens.ROW_HEIGHT); + settingsButton.setMaxHeight(DesignTokens.ROW_HEIGHT); + settingsButton.setMaxWidth(Double.MAX_VALUE); + settingsButton.setAccessibleText("系统设置"); + settingsButton.setFocusTraversable(true); + settingsButton.setOnAction(event -> { + selectSettings(); + openSettings.run(); + }); + } + private void buildBottomToggle() { collapseButton.setId("navigation-rail-toggle"); collapseButton.getStyleClass().addAll(Theme.CSS_NAV_CELL, "navigation-rail-toggle"); @@ -182,6 +226,7 @@ public final class NavigationRail extends VBox { private void activate(MainWindow.NavItem item, ToggleButton button) { selection.selectToggle(button); + settingsButton.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); @@ -212,6 +257,10 @@ public final class NavigationRail extends VBox { collapseButton.setAccessibleText(accessibleText); collapseButton.setTooltip(new Tooltip(accessibleText)); collapseButton.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); + settingsButton.setGraphic(IconFactory.createButtonIcon(AppIcon.SETTINGS, "系统设置")); + settingsButton.setText(isExpanded() ? "系统设置" : null); + settingsButton.setTooltip(isExpanded() ? null : new Tooltip("系统设置")); + settingsButton.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); updateItemAppearance(); } diff --git a/src/test/java/com/example/workutils/system/ui/MainWindowArchitectureTest.java b/src/test/java/com/example/workutils/system/ui/MainWindowArchitectureTest.java index ca23935..784abbb 100644 --- a/src/test/java/com/example/workutils/system/ui/MainWindowArchitectureTest.java +++ b/src/test/java/com/example/workutils/system/ui/MainWindowArchitectureTest.java @@ -25,12 +25,14 @@ class MainWindowArchitectureTest { } @Test - void settingsIsAUniqueHeaderActionInsteadOfANavigationItem() throws Exception { + void settingsUsesTheExplicitShellActionWithoutBecomingABusinessItem() throws Exception { String application = source("src/main/java/com/example/workutils/WorkUtilsApplication.java"); String buildNavItems = between(application, "private List buildNavItems()", "// ==================== 内嵌优先打开"); String mainWindow = source("src/main/java/com/example/workutils/system/ui/MainWindow.java"); + String navigationRail = source( + "src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java"); assertFalse(buildNavItems.contains("__settings__")); assertFalse(buildNavItems.contains("系统设置")); @@ -39,8 +41,8 @@ class MainWindowArchitectureTest { "new MainWindow(navItems, properties, this::openSettingsEmbedded)")); assertTrue(mainWindow.contains( "MainWindow(List navItems, WorkUtilsProperties properties, Runnable openSettings)")); - assertTrue(mainWindow.contains("IconFactory.createButtonIcon(AppIcon.SETTINGS")); - assertTrue(mainWindow.contains("openSettings.run()")); + assertTrue(mainWindow.contains("new NavigationRail(navItems, this::navigateTo, openSettings)")); + assertTrue(navigationRail.contains("openSettings.run()")); } @Test @@ -71,13 +73,12 @@ class MainWindowArchitectureTest { String header = between(mainWindow, "private HBox buildUnifiedHeader", "private void navigateTo"); assertTrue(header.contains("DesignTokens.HEADER_HEIGHT")); - assertTrue(header.contains("IconFactory.createButtonIcon(AppIcon.MENU")); - assertTrue(header.contains("navigationRail.toggle()")); assertTrue(header.contains("IconFactory.create(AppIcon.APP")); assertTrue(header.contains("IconFactory.create(AppIcon.CHEVRON_RIGHT")); - assertTrue(header.contains("IconFactory.createButtonIcon(AppIcon.SETTINGS")); - assertEquals(1, occurrences(header, "AppIcon.SETTINGS")); - assertEquals(1, occurrences(header, "openSettings.run()")); + assertFalse(header.contains("AppIcon.MENU")); + assertFalse(header.contains("AppIcon.SETTINGS")); + assertFalse(header.contains("navigationRail.toggle()")); + assertFalse(header.contains("openSettings.run()")); assertFalse(header.contains("toolbarIcon")); assertFalse(header.contains("SVGPath")); assertFalse(header.contains(" settingsControls = descendants(scene.getRoot()).stream() - .filter(ButtonBase.class::isInstance) - .map(ButtonBase.class::cast) - .filter(Node::isVisible) - .filter(control -> "系统设置".equals(control.getText())) - .toList(); - assertEquals(1, settingsControls.size(), - "Exactly one visible system settings control is required"); - ButtonBase settings = settingsControls.get(0); - assertSame(scene.lookup("#settings-button"), settings); - assertTrue(descendants((Parent) header).contains(settings)); + ButtonBase settings = (ButtonBase) scene.lookup("#navigation-rail-settings"); + assertNotNull(settings); + assertTrue(settings.isVisible()); + assertSame(settings, navigationRail.lookup("#navigation-rail-settings")); + assertFalse(descendants((Parent) header).contains(settings)); + assertEquals("系统设置", settings.getAccessibleText()); Bounds position = settings.localToScene(settings.getBoundsInLocal()); - assertTrue(position.getMinX() > scene.getWidth() / 2, - "System settings must remain at the top-right"); - - boolean settingsInNavigation = descendants(navigationRail).stream() - .filter(Labeled.class::isInstance) - .map(Labeled.class::cast) - .anyMatch(control -> "系统设置".equals(control.getText())); - assertFalse(settingsInNavigation, "System settings must not appear in navigation"); + assertTrue(position.getMaxX() <= navigationRail.getWidth() + 1, + "System settings must remain inside the navigation rail"); + assertNull(scene.lookup("#settings-button")); + assertNull(scene.lookup("#menu-button")); } private void ensureRequestedSizeFitsDesktop(CaptureSpec spec) { 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 118b885..af9b5a3 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 @@ -50,7 +50,7 @@ class NavigationRailTest { "api-doc"); @Test - void headerHasOnlyMenuBrandChevronTitleSpacerAndSettings() throws Exception { + void headerContainsOnlyBrandBreadcrumbTitleAndSpacer() throws Exception { fx(() -> { MainWindow window = new MainWindow(List.of(), new WorkUtilsProperties(), () -> { }); @@ -60,18 +60,14 @@ class NavigationRailTest { stage(window).getScene().getRoot().applyCss(); stage(window).getScene().getRoot().layout(); - assertEquals(6, header.getChildren().size()); - Button menu = assertInstanceOf(Button.class, header.getChildren().get(0)); - HBox brand = assertInstanceOf(HBox.class, header.getChildren().get(1)); - FontIcon chevron = assertInstanceOf(FontIcon.class, header.getChildren().get(2)); - Label title = assertInstanceOf(Label.class, header.getChildren().get(3)); - assertInstanceOf(Region.class, header.getChildren().get(4)); - Button settings = assertInstanceOf(Button.class, header.getChildren().get(5)); + assertEquals(4, header.getChildren().size()); + HBox brand = assertInstanceOf(HBox.class, header.getChildren().get(0)); + FontIcon chevron = assertInstanceOf(FontIcon.class, header.getChildren().get(1)); + Label title = assertInstanceOf(Label.class, header.getChildren().get(2)); + assertInstanceOf(Region.class, header.getChildren().get(3)); - assertEquals(AppIcon.MENU.glyph(), - assertInstanceOf(FontIcon.class, menu.getGraphic()).getIconCode()); - assertEquals("切换导航栏", menu.getAccessibleText()); - assertTrue(menu.isFocusTraversable()); + assertNull(header.lookup("#menu-button")); + assertNull(header.lookup("#settings-button")); assertEquals(2, brand.getChildren().size()); StackPane brandIconContainer = assertInstanceOf( StackPane.class, brand.getChildren().get(0)); @@ -94,12 +90,6 @@ class NavigationRailTest { assertEquals("WorkUtils", brandText.getText()); assertEquals(AppIcon.CHEVRON_RIGHT.glyph(), chevron.getIconCode()); assertEquals("API", title.getText()); - assertEquals(AppIcon.SETTINGS.glyph(), - assertInstanceOf(FontIcon.class, settings.getGraphic()).getIconCode()); - assertEquals("系统设置", settings.getText()); - assertEquals("系统设置", settings.getAccessibleText()); - assertEquals(ContentDisplay.LEFT, settings.getContentDisplay()); - assertTrue(settings.isFocusTraversable()); }); } @@ -153,15 +143,27 @@ class NavigationRailTest { } @Test - void railKeepsCollapseEntryAtBottomWithoutDuplicatingTheApplicationBrand() throws Exception { + void railBottomToolsKeepSettingsAboveTheLastCollapseEntry() throws Exception { fx(() -> { - NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> { + MainWindow window = new MainWindow(List.of(API_DOC), new WorkUtilsProperties(), () -> { }); + NavigationRail rail = assertInstanceOf( + NavigationRail.class, stage(window).getScene().getRoot().lookup(".navigation-rail")); + VBox tools = assertInstanceOf(VBox.class, rail.lookup("#navigation-rail-tools")); + ToggleButton settings = assertInstanceOf( + ToggleButton.class, rail.lookup("#navigation-rail-settings")); Button collapse = assertInstanceOf( Button.class, rail.lookup("#navigation-rail-toggle")); - assertSame(collapse, rail.getChildren().get(rail.getChildren().size() - 1)); + assertEquals(List.of(settings, collapse), tools.getChildren()); + assertSame(tools, rail.getChildren().get(rail.getChildren().size() - 1)); + assertSame(collapse, tools.getChildren().get(tools.getChildren().size() - 1)); assertTrue(rail.lookupAll(".nav-brand").isEmpty()); + assertEquals(AppIcon.SETTINGS.glyph(), + assertInstanceOf(FontIcon.class, settings.getGraphic()).getIconCode()); + assertEquals("系统设置", settings.getText()); + assertEquals("系统设置", settings.getAccessibleText()); + assertNull(settings.getTooltip()); assertEquals("收起", collapse.getText()); assertEquals("收起导航栏", collapse.getAccessibleText()); assertTrue(collapse.isFocusTraversable()); @@ -169,6 +171,9 @@ class NavigationRailTest { collapse.fire(); assertFalse(rail.isExpanded()); + assertNull(settings.getText()); + assertEquals("系统设置", settings.getAccessibleText()); + assertEquals("系统设置", settings.getTooltip().getText()); assertNull(collapse.getText()); assertEquals("展开导航栏", collapse.getAccessibleText()); assertEquals("展开导航栏", collapse.getTooltip().getText()); @@ -212,26 +217,23 @@ class NavigationRailTest { } @Test - void headerMenuAndBottomCollapseEntryDriveTheSameExpandedState() throws Exception { + void headerDoesNotDuplicateTheBottomCollapseEntry() throws Exception { fx(() -> { MainWindow window = new MainWindow(List.of(API_DOC), new WorkUtilsProperties(), () -> { }); Parent root = stage(window).getScene().getRoot(); NavigationRail rail = assertInstanceOf( NavigationRail.class, root.lookup(".navigation-rail")); - Button menu = assertInstanceOf(Button.class, root.lookup("#menu-button")); Button collapse = assertInstanceOf( Button.class, root.lookup("#navigation-rail-toggle")); - menu.fire(); - - assertFalse(rail.isExpanded()); - assertEquals("展开导航栏", collapse.getAccessibleText()); + assertNull(root.lookup("#menu-button")); + assertEquals(1, root.lookupAll("#navigation-rail-toggle").size()); collapse.fire(); - assertTrue(rail.isExpanded()); - assertEquals("收起导航栏", collapse.getAccessibleText()); + assertFalse(rail.isExpanded()); + assertEquals("展开导航栏", collapse.getAccessibleText()); }); } @@ -256,8 +258,8 @@ class NavigationRailTest { Parent root = stage(window).getScene().getRoot(); ToggleButton pluginButton = assertInstanceOf( ToggleButton.class, root.lookup("#nav-api-doc")); - Button settingsButton = assertInstanceOf( - Button.class, root.lookup("#settings-button")); + ToggleButton settingsButton = assertInstanceOf( + ToggleButton.class, root.lookup("#navigation-rail-settings")); NavigationRail rail = assertInstanceOf( NavigationRail.class, root.lookup(".navigation-rail")); @@ -269,11 +271,13 @@ class NavigationRailTest { assertEquals("系统设置", toolbarTitle(window).getText()); assertNull(rail.selectedPluginId()); assertFalse(pluginButton.isSelected()); + assertTrue(settingsButton.isSelected()); pluginButton.fire(); assertEquals(2, pluginOpens.get()); assertEquals("API", toolbarTitle(window).getText()); + assertFalse(settingsButton.isSelected()); }); } @@ -382,7 +386,7 @@ class NavigationRailTest { assertNull(oldButton.getToggleGroup()); assertNull(oldButton.getOnAction()); - assertEquals(1, newButton.getToggleGroup().getToggles().size()); + assertEquals(2, newButton.getToggleGroup().getToggles().size()); oldButton.fire(); assertEquals(1, routed.get()); });