From 855195e286f5db01c056b697af9b3e90fd8d1b0c 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 16:52:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BA=94=E7=94=A8=E5=A4=96?= =?UTF-8?q?=E5=A3=B3=E8=BF=90=E8=A1=8C=E6=97=B6=E6=A0=B7=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workutils/WorkUtilsApplication.java | 9 - .../workutils/system/ui/MainWindow.java | 7 +- .../system/ui/shell/NavigationRail.java | 53 +++-- src/main/resources/css/light-theme.css | 188 +++++------------- src/main/resources/templates/plugins.config | 2 +- .../DefaultPluginConfigurationTest.java | 4 +- .../system/ui/LightThemeCssRuntimeTest.java | 173 ++++++++++++++++ .../system/ui/MainWindowArchitectureTest.java | 22 ++ .../system/ui/shell/NavigationRailTest.java | 162 +++++++++++++++ 9 files changed, 441 insertions(+), 179 deletions(-) create mode 100644 src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java diff --git a/src/main/java/com/example/workutils/WorkUtilsApplication.java b/src/main/java/com/example/workutils/WorkUtilsApplication.java index 370261e..3c48deb 100644 --- a/src/main/java/com/example/workutils/WorkUtilsApplication.java +++ b/src/main/java/com/example/workutils/WorkUtilsApplication.java @@ -114,15 +114,6 @@ public class WorkUtilsApplication implements CommandLineRunner { settingsService.setNavItemsRefresher(ignoredItems -> Platform.runLater(() -> { if (mainWindow != null) { mainWindow.refreshNavItems(buildNavItems()); - // 重新绑定插件面板 - for (Plugin plugin : pluginManager.getEnabledPlugins()) { - PluginDescriptor desc = plugin.getDescriptor(); - VisualPluginFactory factory = plugin.getVisualFactory(); - Parent pane = factory != null ? factory.getEmbeddedPane() : null; - if (pane != null) { - mainWindow.embedPlugin(desc.getId(), desc.getIcon(), desc.getName(), pane); - } - } } })); 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 a78247e..c206ed2 100644 --- a/src/main/java/com/example/workutils/system/ui/MainWindow.java +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -116,6 +116,8 @@ public class MainWindow { 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()); Label brand = new Label("WorkUtils", IconFactory.create(AppIcon.APP, 20)); @@ -135,9 +137,12 @@ public class MainWindow { HBox.setHgrow(spacerRight, Priority.ALWAYS); Button settingsButton = new Button( - null, IconFactory.createButtonIcon(AppIcon.SETTINGS, "系统设置")); + "系统设置", 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( 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 2d3dd39..189bd0c 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 @@ -12,14 +12,11 @@ import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ContentDisplay; -import javafx.scene.control.Label; import javafx.scene.control.Separator; import javafx.scene.control.ToggleButton; import javafx.scene.control.ToggleGroup; import javafx.scene.control.Tooltip; -import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import java.util.ArrayList; @@ -41,7 +38,6 @@ 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 Label brandLabel = new Label("WorkUtils"); private final Button collapseButton = new Button(); private MainWindow.NavItem selectedItem; @@ -53,10 +49,13 @@ public final class NavigationRail extends VBox { this.navigator = Objects.requireNonNull(navigator, "navigator must not be null"); getStyleClass().addAll(Theme.CSS_NAV_BAR, "navigation-rail"); - buildHeader(); itemBox.getStyleClass().add("navigation-rail-items"); VBox.setVgrow(itemBox, Priority.ALWAYS); - getChildren().addAll(new Separator(), itemBox); + buildBottomToggle(); + + Separator separator = new Separator(); + separator.getStyleClass().add(Theme.CSS_NAV_SEP); + getChildren().addAll(itemBox, separator, collapseButton); expanded.addListener((observable, oldValue, newValue) -> updateAppearance()); setItems(items); @@ -131,35 +130,31 @@ public final class NavigationRail extends VBox { return view == null ? null : view.tooltip(); } - private void buildHeader() { - HBox header = new HBox(Theme.S2); - header.setAlignment(Pos.CENTER_LEFT); - header.setMinHeight(DesignTokens.HEADER_HEIGHT); - header.setPrefHeight(DesignTokens.HEADER_HEIGHT); - header.setMaxHeight(DesignTokens.HEADER_HEIGHT); - - brandLabel.setGraphic(IconFactory.create(AppIcon.APP, 18)); - brandLabel.setContentDisplay(ContentDisplay.LEFT); - brandLabel.setFont(Theme.sectionTitle()); - - Region spacer = new Region(); - HBox.setHgrow(spacer, Priority.ALWAYS); - - collapseButton.getStyleClass().add("navigation-rail-toggle"); + private void buildBottomToggle() { + collapseButton.setId("navigation-rail-toggle"); + collapseButton.getStyleClass().addAll(Theme.CSS_NAV_CELL, "navigation-rail-toggle"); + collapseButton.setContentDisplay(ContentDisplay.LEFT); + collapseButton.setGraphicTextGap(Theme.S3); + collapseButton.setMinHeight(DesignTokens.ROW_HEIGHT); + collapseButton.setPrefHeight(DesignTokens.ROW_HEIGHT); + collapseButton.setMaxHeight(DesignTokens.ROW_HEIGHT); + collapseButton.setMaxWidth(Double.MAX_VALUE); + collapseButton.setFocusTraversable(true); collapseButton.setOnAction(event -> toggle()); - header.getChildren().addAll(brandLabel, spacer, collapseButton); - getChildren().add(header); } 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); + button.setGraphicTextGap(Theme.S3); button.setAlignment(Pos.CENTER_LEFT); button.setMinHeight(DesignTokens.ROW_HEIGHT); button.setPrefHeight(DesignTokens.ROW_HEIGHT); button.setMaxHeight(DesignTokens.ROW_HEIGHT); button.setMaxWidth(Double.MAX_VALUE); + button.setAccessibleText(item.name); + button.setFocusTraversable(true); button.getStyleClass().add(Theme.CSS_NAV_CELL); if (item.pluginId != null) { button.setId("nav-" + item.pluginId); @@ -201,12 +196,15 @@ public final class NavigationRail extends VBox { setMaxWidth(width); pseudoClassStateChanged(COLLAPSED, !isExpanded()); - brandLabel.setVisible(isExpanded()); - brandLabel.setManaged(isExpanded()); + String collapseLabel = isExpanded() ? "收起" : "展开"; + String accessibleText = collapseLabel + "导航栏"; collapseButton.setGraphic(IconFactory.createButtonIcon( isExpanded() ? AppIcon.CHEVRON_LEFT : AppIcon.CHEVRON_RIGHT, - isExpanded() ? "折叠导航" : "展开导航")); - collapseButton.setTooltip(new Tooltip(isExpanded() ? "折叠导航" : "展开导航")); + accessibleText)); + collapseButton.setText(isExpanded() ? collapseLabel : null); + collapseButton.setAccessibleText(accessibleText); + collapseButton.setTooltip(new Tooltip(accessibleText)); + collapseButton.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); updateItemAppearance(); } @@ -216,6 +214,7 @@ public final class NavigationRail extends VBox { ToggleButton button = view.button(); button.setText(isExpanded() ? item.name : null); button.setTooltip(isExpanded() ? null : view.tooltip()); + button.setAccessibleText(item.name); button.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); } } diff --git a/src/main/resources/css/light-theme.css b/src/main/resources/css/light-theme.css index 21a0a0a..19fe6b8 100644 --- a/src/main/resources/css/light-theme.css +++ b/src/main/resources/css/light-theme.css @@ -18,17 +18,8 @@ -workutils-success: #10b981; -workutils-primary-light: #dbeafe; - -workutils-font-ui: "Segoe UI"; - -workutils-font-mono: "Consolas"; - -workutils-font-page-title: 20; - -workutils-font-section-title: 15; - -workutils-font-body: 14; - -workutils-font-secondary: 13; - -workutils-row-height: 40; - -workutils-control-height: 36; - -workutils-header-height: 48; - -fx-font-family: -workutils-font-ui; - -fx-font-size: -workutils-font-body; + -fx-font-family: "Segoe UI"; + -fx-font-size: 14px; } /* ---- 导航栏 ---- */ @@ -38,145 +29,64 @@ -fx-border-width: 0 1 0 0; } -.nav-brand { - -fx-text-fill: -workutils-text; - -fx-padding: 16 14 16 14; - -fx-font-weight: 600; -} - .nav-separator { -fx-background-color: -workutils-border; } -/* ============ ListView 导航(JavaFX 标准化方案) ============ */ - -.nav-list { - -fx-background-color: transparent; - -fx-background-insets: 0; - -fx-border-width: 0; - -fx-padding: 0; +/* ============ ToggleButton 导航 ============ */ +.navigation-rail-items { + -fx-padding: 8px 0; } -/* 强制 ListView 所有内部容器透明,确保空区域不显示杂色 */ -.nav-list, -.nav-list > *, -.nav-list .list-view, -.nav-list .viewport, -.nav-list .virtual-flow, -.nav-list .virtual-flow > *, -.nav-list .clipped-container, -.nav-list .sheet, -.nav-list .corner { - -fx-background-color: transparent; - -fx-background-insets: 0; -} - -.nav-list .list-cell, -.nav-list .list-cell:empty, -.nav-list .list-cell:filled:empty { - -fx-background-color: transparent; -} - -/* 滚动条美化 */ -.nav-list .scroll-bar:vertical { - -fx-background-color: transparent; - -fx-pref-width: 6; -} -.nav-list .scroll-bar .thumb { - -fx-background-color: -workutils-border; - -fx-background-radius: 3; -} -.nav-list .scroll-bar .thumb:hover { - -fx-background-color: -workutils-text-muted; -} -.nav-list .scroll-bar .increment-button, -.nav-list .scroll-bar .decrement-button { - -fx-background-color: transparent; - -fx-padding: 0; -} - -/* 每个导航项(Cell) */ .nav-cell { -fx-background-color: transparent; -fx-background-radius: 0; -fx-background-insets: 0; - -fx-padding: 10 14 10 14; - -fx-cursor: hand; - -fx-border-width: 0; - /* 平滑过渡背景色和文字色 */ -} - -.nav-cell .label { + -fx-border-color: transparent; + -fx-border-width: 0 0 0 3px; + -fx-border-radius: 0; + -fx-padding: 0 14px 0 11px; -fx-text-fill: -workutils-text-secondary; - /* 深色背景下的文字阴影,提升对比度 */ + -fx-font-size: 14px; + -fx-font-weight: 500; + -fx-graphic-text-gap: 12px; + -fx-cursor: hand; } -/* 移除 JavaFX ListCell 默认焦点环(让选中态边框干净) */ -.nav-list .list-cell { +.nav-cell .app-icon { + -fx-icon-color: -workutils-text-secondary; } -/* 强制空 cell 完全透明(含 placeholder、footer 等场景) */ -.nav-list .list-cell:empty, -.nav-list .list-cell:filled:empty { - -fx-background-color: transparent !important; -} - -/* hover 状态 */ .nav-cell:hover { -fx-background-color: -workutils-card; } -/* 兜底:所有 selected cell(含 empty)默认透明,防止虚拟化复用时空 cell 闪现蓝色 */ -.nav-list .list-cell:selected { - -fx-background-color: transparent; -} - -/* selected 状态:强制 padding、background-insets、文字色一致 - 关键:用 :filled:selected 而非自定义 class,避免 JavaFX 内置样式覆盖 */ -.nav-list .list-cell:filled:selected { - -fx-background-color: -workutils-primary-light; - -fx-background-insets: 0; - -fx-padding: 10 14 10 14; -} - -.nav-list .list-cell:filled:selected .label { - -fx-text-fill: -workutils-primary !important; - -fx-font-weight: 600; - /* 移除 dropshadow,让文字干净锐利 */ -} - -.nav-list .list-cell:filled:selected .nav-indicator { - -fx-fill: #2563eb; -} - -/* 兼容:之前手动加的 .nav-cell-active 类名也支持 */ +.nav-cell:selected, .nav-cell-active { -fx-background-color: -workutils-primary-light; - -fx-background-insets: 0; - -fx-padding: 10 14 10 14; + -fx-border-color: -workutils-primary; + -fx-border-width: 0 0 0 3px; + -fx-text-fill: -workutils-primary; + -fx-font-weight: 600; +} + +.nav-cell:selected .app-icon, +.nav-cell-active .app-icon { + -fx-icon-color: -workutils-primary; } -/* 按下状态:微妙的反馈 */ .nav-cell:pressed { -fx-background-color: -workutils-primary-light; } -/* ListView 自身获得焦点时也保持透明,不显示蓝色环 */ -/* 左侧指示条 */ -.nav-indicator { - -fx-fill: transparent; - -fx-arc-width: 2; - -fx-arc-height: 2; +.nav-cell:focused { + -fx-border-color: -workutils-primary; + -fx-border-width: 1px 1px 1px 3px; } -.nav-cell-active .nav-indicator { - -fx-fill: #2563eb; -} - -/* 点击涟漪层 */ .nav-version { -fx-text-fill: -workutils-text-muted; - -fx-padding: 10 14 10 14; + -fx-padding: 10px 14px; } /* ---- 顶栏 ---- */ @@ -184,9 +94,9 @@ -fx-background-color: -workutils-surface; -fx-border-width: 0 0 1 0; -fx-border-color: -workutils-border; - -fx-pref-height: -workutils-header-height; - -fx-min-height: -workutils-header-height; - -fx-max-height: -workutils-header-height; + -fx-pref-height: 48px; + -fx-min-height: 48px; + -fx-max-height: 48px; } /* ---- 内容区 ---- */ @@ -204,18 +114,18 @@ } .page-title { - -fx-font-size: -workutils-font-page-title; + -fx-font-size: 20px; -fx-font-weight: 600; -fx-text-fill: -workutils-text; } .body-text { - -fx-font-size: -workutils-font-body; + -fx-font-size: 14px; -fx-text-fill: -workutils-text; } .secondary-text { - -fx-font-size: -workutils-font-secondary; + -fx-font-size: 13px; -fx-text-fill: -workutils-text-secondary; } @@ -226,8 +136,8 @@ .control-default, .text-field-default, .combo-default { - -fx-pref-height: -workutils-control-height; - -fx-min-height: -workutils-control-height; + -fx-pref-height: 36px; + -fx-min-height: 36px; } /* ---- 按钮增强 ---- */ @@ -306,8 +216,8 @@ -fx-border-width: 1; -fx-border-radius: 4; -fx-background-radius: 4; - -fx-font-family: -workutils-font-mono; - -fx-font-size: -workutils-font-body; + -fx-font-family: "Consolas"; + -fx-font-size: 13px; } /* ---- 状态栏 ---- */ @@ -339,25 +249,25 @@ -fx-border-color: -workutils-border; -fx-border-width: 1; -fx-border-radius: 4; - -fx-fixed-cell-size: -workutils-row-height; - -fx-font-size: -workutils-font-body; + -fx-fixed-cell-size: 40px; + -fx-font-size: 13px; } /* ---- 标题 ---- */ .section-title { - -fx-font-size: -workutils-font-section-title; + -fx-font-size: 15px; -fx-font-weight: 600; -fx-text-fill: -workutils-text; } .hint-text { -fx-text-fill: -workutils-text-secondary; - -fx-font-size: -workutils-font-secondary; + -fx-font-size: 13px; } .muted-text { -fx-text-fill: -workutils-text-muted; - -fx-font-size: -workutils-font-secondary; + -fx-font-size: 13px; } .danger-text { @@ -421,13 +331,13 @@ -fx-border-color: -workutils-border; -fx-border-width: 1; -fx-border-radius: 4; - -fx-fixed-cell-size: -workutils-row-height; - -fx-font-size: -workutils-font-body; + -fx-fixed-cell-size: 40px; + -fx-font-size: 13px; } /* ---- 代码文本 ---- */ .mono-text { - -fx-font-family: -workutils-font-mono; + -fx-font-family: "Consolas"; } /* ---- 主题切换按钮 ---- */ @@ -439,7 +349,7 @@ /* ---- 紧凑列表单元格 ---- */ .compact-list .list-cell { -fx-padding: 3 8 3 8; - -fx-font-size: -workutils-font-secondary; + -fx-font-size: 13px; } .compact-list .list-cell:selected { @@ -473,7 +383,7 @@ } .apidoc-tab-pane .tab .tab-label { -fx-text-fill: -workutils-text-secondary; - -fx-font-size: -workutils-font-body; + -fx-font-size: 14px; -fx-font-weight: normal; } .apidoc-tab-pane .tab:selected { diff --git a/src/main/resources/templates/plugins.config b/src/main/resources/templates/plugins.config index fa1b15b..4bb8ab7 100644 --- a/src/main/resources/templates/plugins.config +++ b/src/main/resources/templates/plugins.config @@ -1,5 +1,5 @@ # ====== WorkUtils 插件配置文件 ====== # 插件顺序(按逗号分隔,顺序即菜单显示顺序) # 插件状态(disabled 列表,不在列表中的即为启用) -plugins.order=scan,diff,api-doc,git-sync,git-review,mobile-ui-auto,mybatisexport,mybatisgenerator +plugins.order=git-sync,git-review,diff,mybatisexport,mybatisgenerator,scan,api-doc plugins.disabled=mobile-ui-auto diff --git a/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java b/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java index d9cff56..aecc177 100644 --- a/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java +++ b/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java @@ -41,8 +41,8 @@ class DefaultPluginConfigurationTest { assertEquals(7, manager.getEnabledPlugins().size()); assertEquals( - List.of("scan", "diff", "api-doc", "git-sync", "git-review", - "mybatisexport", "mybatisgenerator"), + List.of("git-sync", "git-review", "diff", "mybatisexport", + "mybatisgenerator", "scan", "api-doc"), manager.getEnabledPlugins().stream().map(Plugin::getId).toList()); } diff --git a/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java b/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java new file mode 100644 index 0000000..21ec4cb --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java @@ -0,0 +1,173 @@ +package com.example.workutils.system.ui; + +import javafx.css.CssParser; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.beans.property.SimpleStringProperty; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.example.workutils.system.ui.FxTestSupport.fx; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LightThemeCssRuntimeTest { + + @Test + void realStylesheetAddsNoJavaFxParserErrors() throws Exception { + fx(() -> { + int errorsBefore = CssParser.errorsProperty().size(); + + createFixture(); + + List newErrors = CssParser.errorsProperty() + .subList(errorsBefore, CssParser.errorsProperty().size()); + assertTrue(newErrors.isEmpty(), () -> "JavaFX CSS parser errors: " + newErrors); + }); + } + + @Test + void semanticClassesComputeApprovedTypographyAndDimensions() throws Exception { + fx(() -> { + Fixture fixture = createFixture(); + + assertEquals(20, fixture.pageTitle().getFont().getSize()); + assertEquals(15, fixture.sectionTitle().getFont().getSize()); + assertEquals(14, fixture.bodyText().getFont().getSize()); + assertEquals(13, fixture.secondaryText().getFont().getSize()); + + assertEquals(13, fixture.logArea().getFont().getSize()); + assertEquals(40, fixture.table().getFixedCellSize()); + assertEquals(40, fixture.tree().getFixedCellSize()); + assertEquals(13, firstTableCell(fixture.table()).getFont().getSize()); + assertEquals(13, firstTreeCell(fixture.tree()).getFont().getSize()); + assertEquals(13, firstListCell(fixture.compactList()).getFont().getSize()); + assertEquals(14, assertInstanceOf( + Label.class, fixture.tabPane().lookup(".tab-label")).getFont().getSize()); + + assertEquals(36, fixture.defaultButton().getMinHeight()); + assertEquals(36, fixture.defaultButton().getPrefHeight()); + assertEquals(36, fixture.textField().getMinHeight()); + assertEquals(36, fixture.comboBox().getPrefHeight()); + assertEquals(48, fixture.header().getMinHeight()); + assertEquals(48, fixture.header().getPrefHeight()); + assertEquals(48, fixture.header().getMaxHeight()); + }); + } + + private static Fixture createFixture() { + Label pageTitle = labelWithStyle(Theme.CSS_PAGE_TITLE); + Label sectionTitle = labelWithStyle(Theme.CSS_SECTION_TITLE); + Label bodyText = labelWithStyle(Theme.CSS_BODY_TEXT); + Label secondaryText = labelWithStyle(Theme.CSS_SECONDARY_TEXT); + + TextArea logArea = new TextArea("log"); + logArea.getStyleClass().add(Theme.CSS_LOG_AREA); + + TableView table = new TableView<>(); + table.getStyleClass().add(Theme.CSS_TABLE); + TableColumn column = new TableColumn<>("Value"); + column.setCellValueFactory(data -> new SimpleStringProperty(data.getValue())); + table.getColumns().add(column); + table.getItems().add("table row"); + table.setPrefHeight(120); + + TreeItem treeRoot = new TreeItem<>("tree row"); + TreeView tree = new TreeView<>(treeRoot); + tree.getStyleClass().add(Theme.CSS_PROJECT_TREE); + tree.setPrefHeight(120); + + ListView compactList = new ListView<>(); + compactList.getStyleClass().add("compact-list"); + compactList.getItems().add("compact row"); + compactList.setPrefHeight(80); + + TabPane tabPane = new TabPane(new Tab("API tab", new Label("content"))); + tabPane.getStyleClass().add("apidoc-tab-pane"); + + Button defaultButton = new Button("Default"); + defaultButton.getStyleClass().add(Theme.CSS_CONTROL_DEFAULT); + TextField textField = new TextField("text"); + textField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + ComboBox comboBox = new ComboBox<>(); + comboBox.getStyleClass().add(Theme.CSS_COMBO); + Region header = new Region(); + header.getStyleClass().add(Theme.CSS_HEADER_BAR); + + VBox root = new VBox(pageTitle, sectionTitle, bodyText, secondaryText, + logArea, table, tree, compactList, tabPane, + defaultButton, textField, comboBox, header); + Scene scene = new Scene(root, 900, 1000); + Theme.applyCustomCss(scene); + root.applyCss(); + root.layout(); + + return new Fixture(pageTitle, sectionTitle, bodyText, secondaryText, + logArea, table, tree, compactList, tabPane, + defaultButton, textField, comboBox, header); + } + + private static Label labelWithStyle(String styleClass) { + Label label = new Label(styleClass); + label.getStyleClass().add(styleClass); + return label; + } + + private static TableCell firstTableCell(TableView table) { + return table.lookupAll(".table-cell").stream() + .filter(TableCell.class::isInstance) + .map(TableCell.class::cast) + .findFirst() + .orElseThrow(); + } + + private static TreeCell firstTreeCell(TreeView tree) { + return tree.lookupAll(".tree-cell").stream() + .filter(TreeCell.class::isInstance) + .map(TreeCell.class::cast) + .findFirst() + .orElseThrow(); + } + + private static ListCell firstListCell(ListView list) { + return list.lookupAll(".list-cell").stream() + .filter(ListCell.class::isInstance) + .map(ListCell.class::cast) + .findFirst() + .orElseThrow(); + } + + private record Fixture( + Label pageTitle, + Label sectionTitle, + Label bodyText, + Label secondaryText, + TextArea logArea, + TableView table, + TreeView tree, + ListView compactList, + TabPane tabPane, + Button defaultButton, + TextField textField, + ComboBox comboBox, + Region header) { + } +} 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 f32a85e..ca23935 100644 --- a/src/test/java/com/example/workutils/system/ui/MainWindowArchitectureTest.java +++ b/src/test/java/com/example/workutils/system/ui/MainWindowArchitectureTest.java @@ -43,6 +43,28 @@ class MainWindowArchitectureTest { assertTrue(mainWindow.contains("openSettings.run()")); } + @Test + void refreshCallbackOnlyRebuildsNavigationAndNeverEagerlyEmbedsPluginPanes() throws Exception { + String application = source("src/main/java/com/example/workutils/WorkUtilsApplication.java"); + String refreshCallback = between(application, + "settingsService.setNavItemsRefresher", + "List navItems = buildNavItems();"); + String buildNavItems = between(application, + "private List buildNavItems()", + "// ==================== 内嵌优先打开"); + String openPlugin = between(application, + "private void openPluginEmbedded", + "// ==================== 独立窗口模式"); + + assertTrue(refreshCallback.contains("mainWindow.refreshNavItems(buildNavItems())")); + assertFalse(refreshCallback.contains("getEmbeddedPane()")); + assertFalse(refreshCallback.contains("mainWindow.embedPlugin(")); + assertFalse(refreshCallback.contains("for (Plugin plugin")); + assertTrue(buildNavItems.contains("() -> openPluginEmbedded(pluginId, plugin, factory)")); + assertTrue(openPlugin.indexOf("plugin.checkConfig()") + < openPlugin.indexOf("factory.getEmbeddedPane()")); + } + @Test void headerUsesDesignTokensAndFeatherSemanticsWithoutCustomWindowChrome() throws Exception { String mainWindow = source("src/main/java/com/example/workutils/system/ui/MainWindow.java"); 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 ff11874..5701bb1 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 @@ -7,13 +7,17 @@ import com.example.workutils.system.ui.icon.AppIcon; import com.example.workutils.system.config.WorkUtilsProperties; import javafx.css.PseudoClass; 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.ToggleButton; import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; import javafx.stage.Stage; import org.junit.jupiter.api.Test; import org.kordamp.ikonli.javafx.FontIcon; @@ -62,12 +66,117 @@ class NavigationRailTest { assertEquals(AppIcon.MENU.glyph(), assertInstanceOf(FontIcon.class, menu.getGraphic()).getIconCode()); + assertEquals("切换导航栏", menu.getAccessibleText()); + assertTrue(menu.isFocusTraversable()); assertEquals(AppIcon.APP.glyph(), assertInstanceOf(FontIcon.class, brand.getGraphic()).getIconCode()); 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()); + }); + } + + @Test + void toggleButtonItemsComputeApprovedGapSelectionHoverAndFocusVisuals() throws Exception { + fx(() -> { + MainWindow.NavItem second = new MainWindow.NavItem( + AppIcon.DIFF, "文本对比", "对比文本", () -> { + }, "diff"); + NavigationRail rail = new NavigationRail(List.of(API_DOC, second), item -> { + }); + VBox root = new VBox(rail); + Scene scene = new Scene(root, DesignTokens.NAV_EXPANDED_WIDTH, 320); + Theme.applyCustomCss(scene); + root.applyCss(); + root.layout(); + + ToggleButton selected = assertInstanceOf( + ToggleButton.class, rail.lookup("#nav-api-doc")); + ToggleButton hover = assertInstanceOf( + ToggleButton.class, rail.lookup("#nav-diff")); + assertEquals(12, selected.getGraphicTextGap()); + assertEquals("API 接口文档导出", selected.getAccessibleText()); + assertTrue(selected.isFocusTraversable()); + + selected.fire(); + root.applyCss(); + root.layout(); + + assertEquals(Color.web("#dbeafe"), + selected.getBackground().getFills().get(0).getFill()); + assertEquals(3, selected.getBorder().getStrokes().get(0).getWidths().getLeft()); + assertEquals(Color.web("#2563eb"), + selected.getBorder().getStrokes().get(0).getLeftStroke()); + assertEquals(Color.web("#2563eb"), selected.getTextFill()); + assertEquals(Color.web("#2563eb"), + assertInstanceOf(FontIcon.class, selected.getGraphic()).getIconColor()); + + hover.pseudoClassStateChanged(PseudoClass.getPseudoClass("hover"), true); + root.applyCss(); + assertEquals(Color.web("#f1f5f9"), + hover.getBackground().getFills().get(0).getFill()); + + selected.pseudoClassStateChanged(PseudoClass.getPseudoClass("focused"), true); + root.applyCss(); + assertEquals(1, selected.getBorder().getStrokes().get(0).getWidths().getTop()); + assertEquals(1, selected.getBorder().getStrokes().get(0).getWidths().getRight()); + assertEquals(1, selected.getBorder().getStrokes().get(0).getWidths().getBottom()); + assertEquals(3, selected.getBorder().getStrokes().get(0).getWidths().getLeft()); + }); + } + + @Test + void railKeepsCollapseEntryAtBottomWithoutDuplicatingTheApplicationBrand() throws Exception { + fx(() -> { + NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> { + }); + + Button collapse = assertInstanceOf( + Button.class, rail.lookup("#navigation-rail-toggle")); + assertSame(collapse, rail.getChildren().get(rail.getChildren().size() - 1)); + assertTrue(rail.lookupAll(".nav-brand").isEmpty()); + assertEquals("收起", collapse.getText()); + assertEquals("收起导航栏", collapse.getAccessibleText()); + assertTrue(collapse.isFocusTraversable()); + + collapse.fire(); + + assertFalse(rail.isExpanded()); + assertNull(collapse.getText()); + assertEquals("展开导航栏", collapse.getAccessibleText()); + assertEquals("展开导航栏", collapse.getTooltip().getText()); + + collapse.fire(); + assertTrue(rail.isExpanded()); + }); + } + + @Test + void headerMenuAndBottomCollapseEntryDriveTheSameExpandedState() 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()); + + collapse.fire(); + + assertTrue(rail.isExpanded()); + assertEquals("收起导航栏", collapse.getAccessibleText()); }); } @@ -224,6 +333,58 @@ class NavigationRailTest { }); } + @Test + void mainWindowRefreshDoesNotRunPluginActionsAndKeepsContentAndSelectionConsistent() throws Exception { + fx(() -> { + AtomicReference windowRef = new AtomicReference<>(); + AtomicInteger firstOpens = new AtomicInteger(); + AtomicInteger secondOpens = new AtomicInteger(); + MainWindow.NavItem first = new MainWindow.NavItem( + AppIcon.DIFF, "First", "First item", () -> { + firstOpens.incrementAndGet(); + StackPane pane = new StackPane(); + pane.setId("pane-first"); + windowRef.get().embedPlugin("first", "legacy", "First", pane); + }, "first"); + MainWindow window = new MainWindow( + List.of(first), new WorkUtilsProperties(), () -> { + }); + windowRef.set(window); + Parent root = stage(window).getScene().getRoot(); + ToggleButton firstButton = assertInstanceOf( + ToggleButton.class, root.lookup("#nav-first")); + firstButton.fire(); + + MainWindow.NavItem second = new MainWindow.NavItem( + AppIcon.SCANNER, "Second", "Second item", () -> { + secondOpens.incrementAndGet(); + StackPane pane = new StackPane(); + pane.setId("pane-second"); + windowRef.get().embedPlugin("second", "legacy", "Second", pane); + }, "second"); + + window.refreshNavItems(List.of(second)); + + NavigationRail rail = assertInstanceOf( + NavigationRail.class, root.lookup(".navigation-rail")); + ToggleButton secondButton = assertInstanceOf( + ToggleButton.class, root.lookup("#nav-second")); + assertNull(root.lookup("#nav-first")); + assertEquals(1, firstOpens.get()); + assertEquals(0, secondOpens.get()); + assertNull(rail.selectedPluginId()); + assertNotNull(root.lookup(".welcome-page")); + assertNull(root.lookup("#pane-first")); + + secondButton.fire(); + + assertEquals(1, secondOpens.get()); + assertEquals("second", rail.selectedPluginId()); + assertNotNull(root.lookup("#pane-second")); + assertNull(root.lookup(".welcome-page")); + }); + } + @Test void collapsedRailUsesExactFixedWidthAndCollapsedPseudoClass() throws Exception { fx(() -> { @@ -255,6 +416,7 @@ class NavigationRailTest { assertNotNull(tooltip); assertEquals("API 接口文档导出", tooltip.getText()); assertSame(tooltip, apiDocButton.getTooltip()); + assertEquals("API 接口文档导出", apiDocButton.getAccessibleText()); }); }