实现可折叠应用导航

This commit is contained in:
张家训
2026-08-01 14:21:02 +08:00
parent 6635117e36
commit 9883ca0950
4 changed files with 485 additions and 359 deletions
@@ -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 重构版
* <p>
* 左侧导航栏(ListView)+ 右侧内容区,使用 CSS 类控制样式。
*/
/** Main application window and shell-level plugin content host. */
public class MainWindow {
private final Stage stage;
private List<NavItem> navItems;
private final WorkUtilsProperties properties;
private final Map<String, EmbeddedEntry> embeddedCache = new LinkedHashMap<>();
private VBox navBox;
private ListView<NavItem> navList;
private List<NavItem> 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<String, EmbeddedEntry> embeddedCache = new LinkedHashMap<>();
/** 响应式缩放因子 */
private double responsiveScale = 1.0;
private Label toolbarIcon;
private Label toolbarTitle;
public MainWindow(List<NavItem> 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<NavItem> 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<Number>() {
@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<NavItem> {
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;
};
}
}
}
@@ -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<MainWindow.NavItem> navigator;
private final ToggleGroup selection = new ToggleGroup();
private final Map<String, MainWindow.NavItem> itemsByPluginId = new LinkedHashMap<>();
private final Map<String, ToggleButton> buttonsByPluginId = new LinkedHashMap<>();
private final Map<String, Tooltip> 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<MainWindow.NavItem> items,
Consumer<MainWindow.NavItem> 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<MainWindow.NavItem> 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<String, ToggleButton> 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");
}
}
}
@@ -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> T fx(Callable<T> action) throws Exception {
FutureTask<T> 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() {
}
}
@@ -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<MainWindow.NavItem> 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());
});
}
}