优化导航底部工具区

This commit is contained in:
张家训
2026-08-01 20:54:22 +08:00
parent bd959825a3
commit 4c873b3f4f
5 changed files with 111 additions and 86 deletions
@@ -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);
@@ -39,6 +39,8 @@ public final class NavigationRail extends VBox {
private final List<ItemView> itemViews = new ArrayList<>();
private final Map<String, ItemView> 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<MainWindow.NavItem> items,
Consumer<MainWindow.NavItem> navigator) {
this(items, navigator, () -> { });
}
public NavigationRail(List<MainWindow.NavItem> items,
Consumer<MainWindow.NavItem> 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();
}
@@ -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<MainWindow.NavItem> 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<NavItem> 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("<svg"));
@@ -19,7 +19,6 @@ import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Label;
import javafx.scene.control.Labeled;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ToggleButton;
import javafx.scene.image.WritableImage;
@@ -47,6 +46,7 @@ import org.kordamp.ikonli.javafx.FontIcon;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -384,26 +384,17 @@ final class NativeUiVisualAcceptanceFixture {
}
private void validateSettingsControl(Scene scene, Node header) {
List<ButtonBase> 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) {
@@ -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());
});