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 18214c6..690e381 100644
--- a/src/main/java/com/example/workutils/system/ui/MainWindow.java
+++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java
@@ -1,159 +1,76 @@
package com.example.workutils.system.ui;
import com.example.workutils.system.config.WorkUtilsProperties;
-import javafx.animation.KeyFrame;
-import javafx.animation.KeyValue;
-import javafx.animation.Timeline;
-import javafx.application.Platform;
-import javafx.collections.FXCollections;
+import com.example.workutils.system.ui.icon.AppIcon;
+import com.example.workutils.system.ui.icon.IconFactory;
+import com.example.workutils.system.ui.shell.NavigationRail;
import javafx.geometry.Insets;
-import javafx.geometry.Orientation;
import javafx.geometry.Pos;
-import javafx.scene.Cursor;
import javafx.scene.Parent;
import javafx.scene.Scene;
-import javafx.scene.control.*;
+import javafx.scene.control.Label;
import javafx.scene.image.Image;
-import javafx.scene.input.MouseEvent;
-import javafx.scene.layout.*;
-import javafx.scene.paint.Color;
-import javafx.scene.shape.Circle;
-import javafx.scene.shape.Rectangle;
-import javafx.scene.text.Font;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
import javafx.scene.text.TextAlignment;
import javafx.stage.Stage;
-import javafx.util.Duration;
+import org.kordamp.ikonli.javafx.FontIcon;
import java.io.InputStream;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
-/**
- * 工作工具集主窗口 —— AtlantaFX 重构版
- *
- * 左侧导航栏(ListView)+ 右侧内容区,使用 CSS 类控制样式。
- */
+/** Main application window and shell-level plugin content host. */
public class MainWindow {
-
private final Stage stage;
- private List navItems;
private final WorkUtilsProperties properties;
+ private final Map embeddedCache = new LinkedHashMap<>();
- private VBox navBox;
- private ListView navList;
+ private List navItems;
+ private NavigationRail navigationRail;
private StackPane contentArea;
private NavItem activeNavItem;
private String activePluginId;
private EmbeddedEntry currentEmbedded;
- /** 顶部工具栏控件 */
private HBox pluginToolbar;
- private Label toolbarIcon, toolbarTitle;
-
- /** 导航栏控件引用(响应式刷新用) */
- private Label appLabel;
- private Label welcomeIcon, welcomeHeading, welcomeDesc;
-
- private final Map embeddedCache = new LinkedHashMap<>();
-
- /** 响应式缩放因子 */
- private double responsiveScale = 1.0;
+ private Label toolbarIcon;
+ private Label toolbarTitle;
public MainWindow(List navItems, WorkUtilsProperties properties) {
- this.navItems = navItems;
+ this.navItems = new ArrayList<>(navItems);
this.properties = properties;
this.stage = new Stage();
buildUI();
}
- // ==================== 响应式字体 ====================
-
- private int rsz(int base) { return Math.max(9, (int) (base * responsiveScale)); }
- private Font rfont(int base) { return Theme.base(rsz(base)); }
- private Font rbold(int base) { return Theme.bold(rsz(base)); }
- private Font rcaption(int base) { return Theme.caption(rsz(base)); }
-
- public double getResponsiveScale() { return responsiveScale; }
-
- private SplitPane splitPane;
-
- private void updateScale(double width) {
- if (Double.isNaN(width) || width <= 0) return;
- double newScale = Math.max(0.85, Math.min(1.4, width / 1280.0));
- // debounce: resize 时频繁触发,延迟 150ms 才真正刷新,避免 UI 卡顿
- if (scaleDebounce != null) scaleDebounce.stop();
- scaleDebounce = new javafx.animation.Timeline(
- new javafx.animation.KeyFrame(javafx.util.Duration.millis(150),
- e -> {
- if (Math.abs(newScale - responsiveScale) > 0.025) {
- responsiveScale = newScale;
- refreshResponsive();
- }
- }));
- scaleDebounce.play();
- }
-
- private javafx.animation.Timeline scaleDebounce;
-
- private void refreshResponsive() {
- appLabel.setFont(rbold(14));
-
- double navMin = computeNavMinBaseWidth() * responsiveScale;
- navBox.setMinWidth(navMin);
- navBox.setMaxWidth(Math.max(navMin + 120, 360));
- if (splitPane.getWidth() > 0 && splitPane.getDividers().size() > 0) {
- double minPos = navMin / splitPane.getWidth();
- if (splitPane.getDividers().get(0).getPosition() < minPos) {
- splitPane.setDividerPosition(0, minPos);
- }
- }
-
- if (welcomeIcon != null) welcomeIcon.setFont(rfont(64));
- if (welcomeHeading != null) welcomeHeading.setFont(rbold(fsTitle() + 4));
- if (welcomeDesc != null) welcomeDesc.setFont(rfont(fsBody()));
-
- toolbarIcon.setFont(rfont(18));
- toolbarTitle.setFont(rbold(14));
-
- // ListView 自动处理 cell 渲染;cell 内部用 rfont 在 updateItem 中读取最新 scale
- if (navList != null) navList.refresh();
- }
-
- private double computeNavMinBaseWidth() {
- // 固定图标区 24px + 间距 8px + 最长文字约 6字符 * 13px ≈ 78px + padding 32px
- return 24 + 8 + 130 + 16;
- }
-
- /**
- * 刷新导航栏(插件启用/禁用后调用)
- */
public void refreshNavItems(List newItems) {
- this.navItems.clear();
- this.navItems.addAll(newItems);
- if (navList != null) {
- navList.setItems(FXCollections.observableArrayList(navItems));
- navList.getSelectionModel().clearSelection();
- }
+ navItems = new ArrayList<>(newItems);
+ navigationRail.setItems(navItems);
showWelcomePage();
}
- private int fsTitle() { return properties.getFontSectionTitle(); }
- private int fsBtn() { return properties.getFontButton(); }
- private int fsBody() { return properties.getFontBody(); }
+ public void show() {
+ stage.show();
+ }
- // ==================== 窗口入口 ====================
+ public void showAndWait() {
+ stage.showAndWait();
+ }
- public void show() { stage.show(); }
- public void showAndWait() { stage.showAndWait(); }
-
- // ==================== UI 构建 ====================
-
- /** 从 classpath 加载应用图标并设置到 Stage */
private void setAppIcon() {
- try (InputStream is = getClass().getClassLoader().getResourceAsStream("app_icon.png")) {
- if (is != null) {
- stage.getIcons().add(new Image(is));
+ try (InputStream input = getClass().getClassLoader().getResourceAsStream("app_icon.png")) {
+ if (input != null) {
+ stage.getIcons().add(new Image(input));
}
} catch (Exception ignored) {
+ // A missing window icon must not prevent the application from starting.
}
}
@@ -164,59 +81,35 @@ public class MainWindow {
stage.setHeight(800);
stage.setMinWidth(1100);
stage.setMinHeight(680);
- stage.setOnCloseRequest(e -> { embeddedCache.clear(); });
-
- stage.widthProperty().addListener((obs, old, w) -> updateScale(w.doubleValue()));
- updateScale(stage.getWidth());
+ stage.setOnCloseRequest(event -> embeddedCache.clear());
BorderPane root = new BorderPane();
root.setTop(buildUnifiedHeader());
- splitPane = new SplitPane();
- splitPane.setOrientation(Orientation.HORIZONTAL);
-
- VBox navBar = buildNavBar();
- double navMinBase = computeNavMinBaseWidth();
- double navMin = navMinBase * responsiveScale;
- navBar.setMinWidth(navMin);
- navBar.setMaxWidth(Math.max(navMin + 120, 360));
- SplitPane.setResizableWithParent(navBar, false);
-
+ navigationRail = new NavigationRail(navItems, this::navigateTo);
contentArea = new StackPane();
contentArea.getStyleClass().add(Theme.CSS_CONTENT_AREA);
- SplitPane.setResizableWithParent(contentArea, true);
+ HBox.setHgrow(contentArea, Priority.ALWAYS);
- // 初始分隔线位置
- splitPane.widthProperty().addListener(new javafx.beans.value.ChangeListener() {
- @Override
- public void changed(javafx.beans.value.ObservableValue extends Number> obs, Number oldW, Number newW) {
- if (newW.doubleValue() > 0) {
- splitPane.setDividerPosition(0, navMin / newW.doubleValue());
- splitPane.widthProperty().removeListener(this);
- }
- }
- });
-
- splitPane.getItems().addAll(navBar, contentArea);
- root.setCenter(splitPane);
+ HBox shell = new HBox(navigationRail, contentArea);
+ root.setCenter(shell);
Scene scene = new Scene(root);
Theme.applyCustomCss(scene);
stage.setScene(scene);
-
showWelcomePage();
}
- // ==================== 统一顶部栏 ====================
-
private HBox buildUnifiedHeader() {
HBox bar = new HBox(Theme.S1);
bar.setAlignment(Pos.CENTER_LEFT);
bar.setPadding(new Insets(0, Theme.S5, 0, Theme.S4));
+ bar.setMinHeight(DesignTokens.HEADER_HEIGHT);
+ bar.setPrefHeight(DesignTokens.HEADER_HEIGHT);
+ bar.setMaxHeight(DesignTokens.HEADER_HEIGHT);
bar.getStyleClass().add(Theme.CSS_HEADER_BAR);
- Label brand = new Label("🛠");
- brand.setFont(rfont(20));
+ FontIcon brand = IconFactory.create(AppIcon.APP, 20);
bar.getChildren().add(brand);
Region spacerLeft = new Region();
@@ -228,21 +121,26 @@ public class MainWindow {
pluginToolbar.setManaged(false);
toolbarIcon = new Label();
- toolbarIcon.setFont(rfont(18));
+ toolbarIcon.setFont(Theme.body());
toolbarTitle = new Label();
- toolbarTitle.setFont(rbold(14));
- toolbarTitle.setTextFill(Theme.TEXT_PRIMARY);
-
+ toolbarTitle.setFont(Theme.body());
pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle);
bar.getChildren().add(pluginToolbar);
Region spacerRight = new Region();
HBox.setHgrow(spacerRight, Priority.ALWAYS);
bar.getChildren().add(spacerRight);
-
return bar;
}
+ private void navigateTo(NavItem item) {
+ activeNavItem = item;
+ if (item.action != null) {
+ item.action.run();
+ }
+ showEmbeddedFor(item);
+ }
+
private void showPluginToolbar(String icon, String title) {
toolbarIcon.setText(icon);
toolbarTitle.setText(title);
@@ -255,190 +153,29 @@ public class MainWindow {
pluginToolbar.setManaged(false);
}
- // ==================== 左侧导航栏 ====================
-
- private VBox buildNavBar() {
- navBox = new VBox(0);
- navBox.getStyleClass().add(Theme.CSS_NAV_BAR);
-
- // 品牌区
- appLabel = new Label(" 🛠 WorkUtils");
- appLabel.setFont(rbold(14));
- appLabel.getStyleClass().add(Theme.CSS_NAV_BRAND);
- appLabel.setMaxWidth(Double.MAX_VALUE);
- navBox.getChildren().add(appLabel);
-
- // 分割线
- Separator sep = new Separator();
- sep.getStyleClass().add(Theme.CSS_NAV_SEP);
- sep.setPadding(new Insets(0));
- navBox.getChildren().add(sep);
-
- // ====== 标准化 ListView 导航 ======
- navList = new ListView<>(FXCollections.observableArrayList(navItems));
- navList.getStyleClass().add(Theme.CSS_NAV_LIST);
- navList.setStyle("-fx-background-color: transparent;"); // inline 兜底
- navList.setCellFactory(lv -> new NavListCell());
- navList.getSelectionModel().selectedItemProperty().addListener(
- (obs, old, newVal) -> {
- if (newVal != null) {
- this.activeNavItem = newVal;
- if (newVal.action != null) newVal.action.run();
- showEmbeddedFor(newVal);
- // 阻止 ListView 默认把选中项滚到中部
- // 延迟到当前事件循环结束后,让我们的滚动覆盖默认行为
- Platform.runLater(() -> {
- int idx = navList.getSelectionModel().getSelectedIndex();
- if (idx > 0) navList.scrollTo(idx - 1);
- });
- }
- });
-
- // ListView 撑满 VBox 剩余空间,内容溢出时自动滚动
- VBox.setVgrow(navList, Priority.ALWAYS);
- navBox.getChildren().add(navList);
-
- return navBox;
- }
-
- /**
- * 自定义导航 Cell —— 包含图标、文本、左侧指示条、点击涟漪。
- * 配合 ListView 自带的选择/键盘/滚动行为。
- */
- private class NavListCell extends ListCell {
-
- private final Rectangle indicator;
- private final Label iconLabel;
- private final Label nameLabel;
- private final Circle ripple;
- private final StackPane cellRoot;
-
- NavListCell() {
- super();
-
- indicator = new Rectangle(4, 28);
- indicator.getStyleClass().add(Theme.CSS_NAV_INDICATOR);
- indicator.setMouseTransparent(true);
-
- iconLabel = new Label();
- iconLabel.setPrefWidth(24);
- iconLabel.setAlignment(Pos.CENTER_LEFT);
- iconLabel.setMouseTransparent(true);
-
- nameLabel = new Label();
- nameLabel.setMouseTransparent(true);
-
- // 涟漪层(点击时扩散)
- ripple = new Circle(0);
- ripple.getStyleClass().add(Theme.CSS_NAV_RIPPLE);
- ripple.setMouseTransparent(true);
- // 关键:不参与 layout,防止动画过程撑大 StackPane 高度
- ripple.setManaged(false);
-
- HBox content = new HBox(10, indicator, iconLabel, nameLabel);
- content.setAlignment(Pos.CENTER_LEFT);
- content.setMouseTransparent(true);
-
- cellRoot = new StackPane(ripple, content);
- cellRoot.setAlignment(Pos.CENTER_LEFT);
-
- setGraphic(cellRoot);
- setText(null);
- getStyleClass().add(Theme.CSS_NAV_CELL);
-
- // 监听 selected 变化以更新 CSS class(必须在 ripple 初始化之后)
- // 直接设置 textFill 绕过 AtlantaFX CSS 优先级问题
- selectedProperty().addListener((obs, old, isSelected) -> {
- updateStyleClass();
- if (isSelected) {
- ripple.setOpacity(0);
- }
- // 强制 text-fill:深色背景统一用白色
- nameLabel.setTextFill(Color.WHITE);
- iconLabel.setTextFill(Color.WHITE);
- });
-
- // 点击涟漪动画
- addEventHandler(MouseEvent.MOUSE_PRESSED, this::onCellPressed);
- }
-
- @Override
- protected void updateItem(NavItem item, boolean empty) {
- super.updateItem(item, empty);
- if (empty || item == null) {
- setGraphic(null);
- updateStyleClass();
- return;
- }
- iconLabel.setText(item.icon);
- iconLabel.setFont(rfont(13));
- nameLabel.setText(item.name);
- nameLabel.setFont(rfont(13));
- // 直接在代码中强制 text-fill,深色导航栏统一用白色
- nameLabel.setTextFill(Color.WHITE);
- iconLabel.setTextFill(Color.WHITE);
- setGraphic(cellRoot);
- updateStyleClass();
- }
-
- private void updateStyleClass() {
- getStyleClass().removeAll(Theme.CSS_NAV_CELL, Theme.CSS_NAV_CELL_ACTIVE);
- if (isEmpty()) return;
- boolean active = isSelected();
- getStyleClass().add(active ? Theme.CSS_NAV_CELL_ACTIVE : Theme.CSS_NAV_CELL);
- }
-
- /** 点击涟漪扩散动画 */
- private void onCellPressed(MouseEvent e) {
- if (isEmpty()) return;
- double x = e.getX();
- double y = e.getY();
- ripple.setCenterX(x);
- ripple.setCenterY(y);
- ripple.setRadius(0);
- ripple.setOpacity(0.6);
-
- double maxRadius = Math.max(getWidth(), getHeight());
- Timeline anim = new Timeline(
- new KeyFrame(Duration.ZERO,
- new KeyValue(ripple.radiusProperty(), 0)),
- new KeyFrame(Duration.millis(500),
- new KeyValue(ripple.radiusProperty(), maxRadius),
- new KeyValue(ripple.opacityProperty(), 0))
- );
- // 关键:动画结束必须重置 radius 为 0,否则 Circle 会撑大 StackPane 高度
- anim.setOnFinished(ev -> {
- ripple.setRadius(0);
- ripple.setOpacity(0);
- });
- anim.play();
- }
- }
-
- // ==================== 欢迎页 ====================
-
private void showWelcomePage() {
VBox welcome = new VBox(20);
welcome.setAlignment(Pos.CENTER);
welcome.setPadding(new Insets(60));
welcome.getStyleClass().add(Theme.CSS_WELCOME_PAGE);
- welcomeIcon = new Label("🛠");
- welcomeIcon.setFont(rfont(64));
-
- welcomeHeading = new Label("WorkUtils 开发工作集");
- welcomeHeading.setFont(rbold(fsTitle() + 4));
+ FontIcon welcomeIcon = IconFactory.create(AppIcon.APP, 64);
+ Label welcomeHeading = new Label("WorkUtils 开发工作集");
+ welcomeHeading.setFont(Theme.pageTitle());
welcomeHeading.getStyleClass().add(Theme.CSS_WELCOME_HEADING);
- welcomeDesc = new Label("一站式开发工具平台 · 文本对比 · 代码生成 · Git 同步\nAPI 文档导出 · SQL 导出 · 关键词扫描 · 系统设置");
- welcomeDesc.setFont(rfont(fsBody()));
- welcomeDesc.getStyleClass().add(Theme.CSS_WELCOME_DESC);
- welcomeDesc.setTextAlignment(TextAlignment.CENTER);
- welcomeDesc.setMaxWidth(560);
+ Label welcomeDescription = new Label(
+ "一站式开发工具平台 · 文本对比 · 代码生成 · Git 同步\n"
+ + "API 文档导出 · SQL 导出 · 关键词扫描 · 系统设置");
+ welcomeDescription.setFont(Theme.body());
+ welcomeDescription.getStyleClass().add(Theme.CSS_WELCOME_DESC);
+ welcomeDescription.setTextAlignment(TextAlignment.CENTER);
+ welcomeDescription.setMaxWidth(560);
- welcome.getChildren().addAll(welcomeIcon, welcomeHeading, welcomeDesc);
+ welcome.getChildren().addAll(welcomeIcon, welcomeHeading, welcomeDescription);
contentArea.getChildren().setAll(welcome);
hidePluginToolbar();
+ activeNavItem = null;
activePluginId = null;
currentEmbedded = null;
}
@@ -454,12 +191,8 @@ public class MainWindow {
}
}
- // ==================== 内嵌管理 ====================
-
public void embedPlugin(String pluginId, String icon, String name, Parent content) {
- EmbeddedEntry existing = embeddedCache.get(pluginId);
- if (existing != null) { embeddedCache.remove(pluginId); }
-
+ embeddedCache.remove(pluginId);
EmbeddedEntry entry = new EmbeddedEntry(content, icon, name);
embeddedCache.put(pluginId, entry);
switchToEmbedded(pluginId, entry);
@@ -469,29 +202,32 @@ public class MainWindow {
if (currentEmbedded != null && currentEmbedded != entry) {
contentArea.getChildren().remove(currentEmbedded.wrapper);
}
- this.activePluginId = pluginId;
- this.currentEmbedded = entry;
+ activePluginId = pluginId;
+ currentEmbedded = entry;
if (entry.wrapper == null) {
entry.wrapper = new BorderPane();
entry.wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA);
entry.wrapper.setCenter(entry.content);
}
-
if (!contentArea.getChildren().contains(entry.wrapper)) {
contentArea.getChildren().setAll(entry.wrapper);
}
-
showPluginToolbar(entry.icon, entry.title);
}
public void closePlugin(String pluginId) {
EmbeddedEntry entry = embeddedCache.remove(pluginId);
- if (entry != null) {
- if (entry.wrapper != null) { contentArea.getChildren().remove(entry.wrapper); }
- if (currentEmbedded == entry) {
- currentEmbedded = null; activePluginId = null; showWelcomePage();
- }
+ if (entry == null) {
+ return;
+ }
+ if (entry.wrapper != null) {
+ contentArea.getChildren().remove(entry.wrapper);
+ }
+ if (currentEmbedded == entry) {
+ currentEmbedded = null;
+ activePluginId = null;
+ showWelcomePage();
}
}
@@ -509,34 +245,67 @@ public class MainWindow {
showPluginToolbar(icon, title);
}
- // ==================== 主题刷新(供设置窗口调用) ====================
-
public void refreshTheme() {
Theme.applyCustomCss(stage.getScene());
}
- // ==================== 内嵌条目 ====================
+ private static final class EmbeddedEntry {
+ private final Parent content;
+ private final String icon;
+ private final String title;
+ private BorderPane wrapper;
- private static class EmbeddedEntry {
- final Parent content;
- final String icon, title;
- BorderPane wrapper;
-
- EmbeddedEntry(Parent c, String ic, String t) {
- content=c; icon=ic; title=t;
+ private EmbeddedEntry(Parent content, String icon, String title) {
+ this.content = content;
+ this.icon = icon;
+ this.title = title;
}
}
- // ==================== 导航项 ====================
-
public static class NavItem {
- public final String icon, name, description, pluginId;
+ public final AppIcon icon;
+ public final String name;
+ public final String description;
+ public final String pluginId;
public final Runnable action;
- public NavItem(String i, String n, String d, Runnable a) {
- this(i, n, d, a, null);
+
+ public NavItem(AppIcon icon, String name, String description, Runnable action) {
+ this(icon, name, description, action, null);
}
- public NavItem(String i, String n, String d, Runnable a, String pid) {
- icon=i; name=n; description=d; action=a; pluginId=pid;
+
+ public NavItem(AppIcon icon, String name, String description, Runnable action, String pluginId) {
+ this.icon = icon;
+ this.name = name;
+ this.description = description;
+ this.action = action;
+ this.pluginId = pluginId;
+ }
+
+ /** Compatibility bridge while plugin descriptors still expose their legacy icon text. */
+ public NavItem(String ignoredIcon, String name, String description, Runnable action) {
+ this(iconForPlugin(null), name, description, action, null);
+ }
+
+ /** Compatibility bridge while plugin descriptors still expose their legacy icon text. */
+ public NavItem(String ignoredIcon, String name, String description, Runnable action, String pluginId) {
+ this(iconForPlugin(pluginId), name, description, action, pluginId);
+ }
+
+ private static AppIcon iconForPlugin(String pluginId) {
+ if (pluginId == null) {
+ return AppIcon.APP;
+ }
+ return switch (pluginId) {
+ case "scan" -> AppIcon.SCANNER;
+ case "diff" -> AppIcon.DIFF;
+ case "api-doc" -> AppIcon.API_DOC;
+ case "git-sync" -> AppIcon.GIT_SYNC;
+ case "git-review" -> AppIcon.GIT_REVIEW;
+ case "mybatisexport" -> AppIcon.SQL_EXPORT;
+ case "mybatisgenerator" -> AppIcon.MYBATIS_GENERATOR;
+ 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
new file mode 100644
index 0000000..603fee3
--- /dev/null
+++ b/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java
@@ -0,0 +1,211 @@
+package com.example.workutils.system.ui.shell;
+
+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.ui.icon.IconFactory;
+import javafx.application.Platform;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Application-level navigation with a fixed expanded or collapsed width.
+ */
+public final class NavigationRail extends VBox {
+ private static final PseudoClass COLLAPSED = PseudoClass.getPseudoClass("collapsed");
+
+ 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 VBox itemBox = new VBox();
+ private final Label brandLabel = new Label("WorkUtils");
+ private final Button collapseButton = new Button();
+
+ private MainWindow.NavItem selectedItem;
+ private String selectedPluginId;
+
+ public NavigationRail(List items,
+ Consumer navigator) {
+ requireFxThread();
+ 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);
+
+ expanded.addListener((observable, oldValue, newValue) -> updateAppearance());
+ setItems(items);
+ updateAppearance();
+ }
+
+ public ReadOnlyBooleanProperty expandedProperty() {
+ return expanded.getReadOnlyProperty();
+ }
+
+ public boolean isExpanded() {
+ return expanded.get();
+ }
+
+ public void setExpanded(boolean value) {
+ requireFxThread();
+ expanded.set(value);
+ }
+
+ public void toggle() {
+ requireFxThread();
+ setExpanded(!isExpanded());
+ }
+
+ public void selectPlugin(String pluginId) {
+ requireFxThread();
+ ToggleButton button = buttonsByPluginId.get(pluginId);
+ MainWindow.NavItem item = itemsByPluginId.get(pluginId);
+ if (button == null || item == null) {
+ return;
+ }
+ activate(item, button);
+ }
+
+ public String selectedPluginId() {
+ return selectedPluginId;
+ }
+
+ public void setItems(List items) {
+ requireFxThread();
+ Objects.requireNonNull(items, "items must not be null");
+
+ selection.selectToggle(null);
+ itemBox.getChildren().clear();
+ itemsByPluginId.clear();
+ buttonsByPluginId.clear();
+ tooltipsByPluginId.clear();
+ selectedItem = null;
+ selectedPluginId = null;
+
+ for (MainWindow.NavItem item : items) {
+ addItem(Objects.requireNonNull(item, "navigation item must not be null"));
+ }
+ updateItemAppearance();
+ }
+
+ Tooltip tooltipFor(String pluginId) {
+ return tooltipsByPluginId.get(pluginId);
+ }
+
+ 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");
+ collapseButton.setOnAction(event -> toggle());
+ header.getChildren().addAll(brandLabel, spacer, collapseButton);
+ getChildren().add(header);
+ }
+
+ private void addItem(MainWindow.NavItem item) {
+ ToggleButton button = new ToggleButton(item.name, IconFactory.createButtonIcon(item.icon, item.name));
+ button.setToggleGroup(selection);
+ button.setContentDisplay(ContentDisplay.LEFT);
+ 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.getStyleClass().add(Theme.CSS_NAV_CELL);
+ if (item.pluginId != null) {
+ button.setId("nav-" + item.pluginId);
+ }
+
+ 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);
+ 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));
+ if (!button.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) {
+ button.getStyleClass().add(Theme.CSS_NAV_CELL_ACTIVE);
+ }
+ if (selectedItem == item) {
+ return;
+ }
+ selectedItem = item;
+ selectedPluginId = item.pluginId;
+ navigator.accept(item);
+ }
+
+ private void updateAppearance() {
+ int width = isExpanded()
+ ? DesignTokens.NAV_EXPANDED_WIDTH
+ : DesignTokens.NAV_COLLAPSED_WIDTH;
+ setMinWidth(width);
+ setPrefWidth(width);
+ setMaxWidth(width);
+ pseudoClassStateChanged(COLLAPSED, !isExpanded());
+
+ brandLabel.setVisible(isExpanded());
+ brandLabel.setManaged(isExpanded());
+ collapseButton.setGraphic(IconFactory.createButtonIcon(
+ isExpanded() ? AppIcon.CHEVRON_LEFT : AppIcon.CHEVRON_RIGHT,
+ isExpanded() ? "折叠导航" : "展开导航"));
+ collapseButton.setTooltip(new Tooltip(isExpanded() ? "折叠导航" : "展开导航"));
+ updateItemAppearance();
+ }
+
+ private void updateItemAppearance() {
+ for (Map.Entry entry : buttonsByPluginId.entrySet()) {
+ MainWindow.NavItem item = itemsByPluginId.get(entry.getKey());
+ ToggleButton button = entry.getValue();
+ button.setText(isExpanded() ? item.name : null);
+ button.setTooltip(isExpanded() ? null : tooltipsByPluginId.get(entry.getKey()));
+ button.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER);
+ }
+ }
+
+ 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/FxTestSupport.java b/src/test/java/com/example/workutils/system/ui/FxTestSupport.java
new file mode 100644
index 0000000..831b58e
--- /dev/null
+++ b/src/test/java/com/example/workutils/system/ui/FxTestSupport.java
@@ -0,0 +1,43 @@
+package com.example.workutils.system.ui;
+
+import javafx.application.Platform;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+
+public final class FxTestSupport {
+ static {
+ try {
+ CountDownLatch started = new CountDownLatch(1);
+ Platform.startup(started::countDown);
+ started.await();
+ } catch (IllegalStateException alreadyStarted) {
+ // JavaFX toolkit is already running in this test JVM.
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(interrupted);
+ }
+ }
+
+ public static T fx(Callable action) throws Exception {
+ FutureTask task = new FutureTask<>(action);
+ Platform.runLater(task);
+ return task.get();
+ }
+
+ public static void fx(ThrowingRunnable action) throws Exception {
+ fx(() -> {
+ action.run();
+ return null;
+ });
+ }
+
+ @FunctionalInterface
+ public interface ThrowingRunnable {
+ void run() throws Exception;
+ }
+
+ private FxTestSupport() {
+ }
+}
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
new file mode 100644
index 0000000..362867b
--- /dev/null
+++ b/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java
@@ -0,0 +1,103 @@
+package com.example.workutils.system.ui.shell;
+
+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 javafx.css.PseudoClass;
+import javafx.scene.control.ToggleButton;
+import javafx.scene.control.Tooltip;
+import org.junit.jupiter.api.Test;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class NavigationRailTest {
+
+ private static final MainWindow.NavItem API_DOC = new MainWindow.NavItem(
+ AppIcon.API_DOC,
+ "API 接口文档导出",
+ "导出接口文档",
+ () -> {
+ },
+ "api-doc");
+
+ @Test
+ void collapsePreservesSelectionAndDoesNotRouteAgain() throws Exception {
+ fx(() -> {
+ AtomicInteger routed = new AtomicInteger();
+ AtomicReference selected = new AtomicReference<>();
+ NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> {
+ selected.set(item);
+ routed.incrementAndGet();
+ });
+
+ rail.selectPlugin("api-doc");
+ rail.setExpanded(false);
+
+ ToggleButton apiDocButton = (ToggleButton) rail.lookup("#nav-api-doc");
+ assertEquals("api-doc", rail.selectedPluginId());
+ assertEquals(API_DOC, selected.get());
+ assertEquals(1, routed.get());
+ assertTrue(apiDocButton.isSelected());
+ assertTrue(apiDocButton.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE));
+ });
+ }
+
+ @Test
+ void reselectingCurrentPluginKeepsItSelectedWithoutRoutingAgain() throws Exception {
+ fx(() -> {
+ AtomicInteger routed = new AtomicInteger();
+ NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> routed.incrementAndGet());
+ ToggleButton apiDocButton = (ToggleButton) rail.lookup("#nav-api-doc");
+
+ apiDocButton.fire();
+ apiDocButton.fire();
+
+ assertTrue(apiDocButton.isSelected());
+ assertEquals("api-doc", rail.selectedPluginId());
+ assertEquals(1, routed.get());
+ });
+ }
+
+ @Test
+ void collapsedRailUsesExactFixedWidthAndCollapsedPseudoClass() throws Exception {
+ fx(() -> {
+ NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> {
+ });
+
+ assertEquals(DesignTokens.NAV_EXPANDED_WIDTH, rail.getMinWidth());
+ assertEquals(DesignTokens.NAV_EXPANDED_WIDTH, rail.getPrefWidth());
+ assertEquals(DesignTokens.NAV_EXPANDED_WIDTH, rail.getMaxWidth());
+ rail.setExpanded(false);
+
+ assertEquals(DesignTokens.NAV_COLLAPSED_WIDTH, rail.getMinWidth());
+ assertEquals(DesignTokens.NAV_COLLAPSED_WIDTH, rail.getPrefWidth());
+ assertEquals(DesignTokens.NAV_COLLAPSED_WIDTH, rail.getMaxWidth());
+ assertTrue(rail.getPseudoClassStates().contains(PseudoClass.getPseudoClass("collapsed")));
+ });
+ }
+
+ @Test
+ void collapsedItemKeepsARealTooltipWithItsVisibleLabel() throws Exception {
+ fx(() -> {
+ NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> {
+ });
+
+ rail.setExpanded(false);
+
+ Tooltip tooltip = rail.tooltipFor("api-doc");
+ ToggleButton apiDocButton = (ToggleButton) rail.lookup("#nav-api-doc");
+ assertNotNull(tooltip);
+ assertEquals("API 接口文档导出", tooltip.getText());
+ assertSame(tooltip, apiDocButton.getTooltip());
+ });
+ }
+}