实现可折叠应用导航
This commit is contained in:
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user