实现可折叠应用导航
This commit is contained in:
@@ -1,159 +1,76 @@
|
|||||||
package com.example.workutils.system.ui;
|
package com.example.workutils.system.ui;
|
||||||
|
|
||||||
import com.example.workutils.system.config.WorkUtilsProperties;
|
import com.example.workutils.system.config.WorkUtilsProperties;
|
||||||
import javafx.animation.KeyFrame;
|
import com.example.workutils.system.ui.icon.AppIcon;
|
||||||
import javafx.animation.KeyValue;
|
import com.example.workutils.system.ui.icon.IconFactory;
|
||||||
import javafx.animation.Timeline;
|
import com.example.workutils.system.ui.shell.NavigationRail;
|
||||||
import javafx.application.Platform;
|
|
||||||
import javafx.collections.FXCollections;
|
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Orientation;
|
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.Cursor;
|
|
||||||
import javafx.scene.Parent;
|
import javafx.scene.Parent;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.control.*;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
import javafx.scene.input.MouseEvent;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.*;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.shape.Circle;
|
import javafx.scene.layout.Region;
|
||||||
import javafx.scene.shape.Rectangle;
|
import javafx.scene.layout.StackPane;
|
||||||
import javafx.scene.text.Font;
|
import javafx.scene.layout.VBox;
|
||||||
import javafx.scene.text.TextAlignment;
|
import javafx.scene.text.TextAlignment;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
import javafx.util.Duration;
|
import org.kordamp.ikonli.javafx.FontIcon;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/** Main application window and shell-level plugin content host. */
|
||||||
* 工作工具集主窗口 —— AtlantaFX 重构版
|
|
||||||
* <p>
|
|
||||||
* 左侧导航栏(ListView)+ 右侧内容区,使用 CSS 类控制样式。
|
|
||||||
*/
|
|
||||||
public class MainWindow {
|
public class MainWindow {
|
||||||
|
|
||||||
private final Stage stage;
|
private final Stage stage;
|
||||||
private List<NavItem> navItems;
|
|
||||||
private final WorkUtilsProperties properties;
|
private final WorkUtilsProperties properties;
|
||||||
|
private final Map<String, EmbeddedEntry> embeddedCache = new LinkedHashMap<>();
|
||||||
|
|
||||||
private VBox navBox;
|
private List<NavItem> navItems;
|
||||||
private ListView<NavItem> navList;
|
private NavigationRail navigationRail;
|
||||||
private StackPane contentArea;
|
private StackPane contentArea;
|
||||||
private NavItem activeNavItem;
|
private NavItem activeNavItem;
|
||||||
private String activePluginId;
|
private String activePluginId;
|
||||||
private EmbeddedEntry currentEmbedded;
|
private EmbeddedEntry currentEmbedded;
|
||||||
|
|
||||||
/** 顶部工具栏控件 */
|
|
||||||
private HBox pluginToolbar;
|
private HBox pluginToolbar;
|
||||||
private Label toolbarIcon, toolbarTitle;
|
private Label toolbarIcon;
|
||||||
|
private Label toolbarTitle;
|
||||||
/** 导航栏控件引用(响应式刷新用) */
|
|
||||||
private Label appLabel;
|
|
||||||
private Label welcomeIcon, welcomeHeading, welcomeDesc;
|
|
||||||
|
|
||||||
private final Map<String, EmbeddedEntry> embeddedCache = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
/** 响应式缩放因子 */
|
|
||||||
private double responsiveScale = 1.0;
|
|
||||||
|
|
||||||
public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties) {
|
public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties) {
|
||||||
this.navItems = navItems;
|
this.navItems = new ArrayList<>(navItems);
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.stage = new Stage();
|
this.stage = new Stage();
|
||||||
buildUI();
|
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) {
|
public void refreshNavItems(List<NavItem> newItems) {
|
||||||
this.navItems.clear();
|
navItems = new ArrayList<>(newItems);
|
||||||
this.navItems.addAll(newItems);
|
navigationRail.setItems(navItems);
|
||||||
if (navList != null) {
|
|
||||||
navList.setItems(FXCollections.observableArrayList(navItems));
|
|
||||||
navList.getSelectionModel().clearSelection();
|
|
||||||
}
|
|
||||||
showWelcomePage();
|
showWelcomePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int fsTitle() { return properties.getFontSectionTitle(); }
|
public void show() {
|
||||||
private int fsBtn() { return properties.getFontButton(); }
|
stage.show();
|
||||||
private int fsBody() { return properties.getFontBody(); }
|
}
|
||||||
|
|
||||||
// ==================== 窗口入口 ====================
|
public void showAndWait() {
|
||||||
|
stage.showAndWait();
|
||||||
|
}
|
||||||
|
|
||||||
public void show() { stage.show(); }
|
|
||||||
public void showAndWait() { stage.showAndWait(); }
|
|
||||||
|
|
||||||
// ==================== UI 构建 ====================
|
|
||||||
|
|
||||||
/** 从 classpath 加载应用图标并设置到 Stage */
|
|
||||||
private void setAppIcon() {
|
private void setAppIcon() {
|
||||||
try (InputStream is = getClass().getClassLoader().getResourceAsStream("app_icon.png")) {
|
try (InputStream input = getClass().getClassLoader().getResourceAsStream("app_icon.png")) {
|
||||||
if (is != null) {
|
if (input != null) {
|
||||||
stage.getIcons().add(new Image(is));
|
stage.getIcons().add(new Image(input));
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} 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.setHeight(800);
|
||||||
stage.setMinWidth(1100);
|
stage.setMinWidth(1100);
|
||||||
stage.setMinHeight(680);
|
stage.setMinHeight(680);
|
||||||
stage.setOnCloseRequest(e -> { embeddedCache.clear(); });
|
stage.setOnCloseRequest(event -> embeddedCache.clear());
|
||||||
|
|
||||||
stage.widthProperty().addListener((obs, old, w) -> updateScale(w.doubleValue()));
|
|
||||||
updateScale(stage.getWidth());
|
|
||||||
|
|
||||||
BorderPane root = new BorderPane();
|
BorderPane root = new BorderPane();
|
||||||
root.setTop(buildUnifiedHeader());
|
root.setTop(buildUnifiedHeader());
|
||||||
|
|
||||||
splitPane = new SplitPane();
|
navigationRail = new NavigationRail(navItems, this::navigateTo);
|
||||||
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);
|
|
||||||
|
|
||||||
contentArea = new StackPane();
|
contentArea = new StackPane();
|
||||||
contentArea.getStyleClass().add(Theme.CSS_CONTENT_AREA);
|
contentArea.getStyleClass().add(Theme.CSS_CONTENT_AREA);
|
||||||
SplitPane.setResizableWithParent(contentArea, true);
|
HBox.setHgrow(contentArea, Priority.ALWAYS);
|
||||||
|
|
||||||
// 初始分隔线位置
|
HBox shell = new HBox(navigationRail, contentArea);
|
||||||
splitPane.widthProperty().addListener(new javafx.beans.value.ChangeListener<Number>() {
|
root.setCenter(shell);
|
||||||
@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);
|
|
||||||
|
|
||||||
Scene scene = new Scene(root);
|
Scene scene = new Scene(root);
|
||||||
Theme.applyCustomCss(scene);
|
Theme.applyCustomCss(scene);
|
||||||
stage.setScene(scene);
|
stage.setScene(scene);
|
||||||
|
|
||||||
showWelcomePage();
|
showWelcomePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 统一顶部栏 ====================
|
|
||||||
|
|
||||||
private HBox buildUnifiedHeader() {
|
private HBox buildUnifiedHeader() {
|
||||||
HBox bar = new HBox(Theme.S1);
|
HBox bar = new HBox(Theme.S1);
|
||||||
bar.setAlignment(Pos.CENTER_LEFT);
|
bar.setAlignment(Pos.CENTER_LEFT);
|
||||||
bar.setPadding(new Insets(0, Theme.S5, 0, Theme.S4));
|
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);
|
bar.getStyleClass().add(Theme.CSS_HEADER_BAR);
|
||||||
|
|
||||||
Label brand = new Label("🛠");
|
FontIcon brand = IconFactory.create(AppIcon.APP, 20);
|
||||||
brand.setFont(rfont(20));
|
|
||||||
bar.getChildren().add(brand);
|
bar.getChildren().add(brand);
|
||||||
|
|
||||||
Region spacerLeft = new Region();
|
Region spacerLeft = new Region();
|
||||||
@@ -228,21 +121,26 @@ public class MainWindow {
|
|||||||
pluginToolbar.setManaged(false);
|
pluginToolbar.setManaged(false);
|
||||||
|
|
||||||
toolbarIcon = new Label();
|
toolbarIcon = new Label();
|
||||||
toolbarIcon.setFont(rfont(18));
|
toolbarIcon.setFont(Theme.body());
|
||||||
toolbarTitle = new Label();
|
toolbarTitle = new Label();
|
||||||
toolbarTitle.setFont(rbold(14));
|
toolbarTitle.setFont(Theme.body());
|
||||||
toolbarTitle.setTextFill(Theme.TEXT_PRIMARY);
|
|
||||||
|
|
||||||
pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle);
|
pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle);
|
||||||
bar.getChildren().add(pluginToolbar);
|
bar.getChildren().add(pluginToolbar);
|
||||||
|
|
||||||
Region spacerRight = new Region();
|
Region spacerRight = new Region();
|
||||||
HBox.setHgrow(spacerRight, Priority.ALWAYS);
|
HBox.setHgrow(spacerRight, Priority.ALWAYS);
|
||||||
bar.getChildren().add(spacerRight);
|
bar.getChildren().add(spacerRight);
|
||||||
|
|
||||||
return bar;
|
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) {
|
private void showPluginToolbar(String icon, String title) {
|
||||||
toolbarIcon.setText(icon);
|
toolbarIcon.setText(icon);
|
||||||
toolbarTitle.setText(title);
|
toolbarTitle.setText(title);
|
||||||
@@ -255,190 +153,29 @@ public class MainWindow {
|
|||||||
pluginToolbar.setManaged(false);
|
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() {
|
private void showWelcomePage() {
|
||||||
VBox welcome = new VBox(20);
|
VBox welcome = new VBox(20);
|
||||||
welcome.setAlignment(Pos.CENTER);
|
welcome.setAlignment(Pos.CENTER);
|
||||||
welcome.setPadding(new Insets(60));
|
welcome.setPadding(new Insets(60));
|
||||||
welcome.getStyleClass().add(Theme.CSS_WELCOME_PAGE);
|
welcome.getStyleClass().add(Theme.CSS_WELCOME_PAGE);
|
||||||
|
|
||||||
welcomeIcon = new Label("🛠");
|
FontIcon welcomeIcon = IconFactory.create(AppIcon.APP, 64);
|
||||||
welcomeIcon.setFont(rfont(64));
|
Label welcomeHeading = new Label("WorkUtils 开发工作集");
|
||||||
|
welcomeHeading.setFont(Theme.pageTitle());
|
||||||
welcomeHeading = new Label("WorkUtils 开发工作集");
|
|
||||||
welcomeHeading.setFont(rbold(fsTitle() + 4));
|
|
||||||
welcomeHeading.getStyleClass().add(Theme.CSS_WELCOME_HEADING);
|
welcomeHeading.getStyleClass().add(Theme.CSS_WELCOME_HEADING);
|
||||||
|
|
||||||
welcomeDesc = new Label("一站式开发工具平台 · 文本对比 · 代码生成 · Git 同步\nAPI 文档导出 · SQL 导出 · 关键词扫描 · 系统设置");
|
Label welcomeDescription = new Label(
|
||||||
welcomeDesc.setFont(rfont(fsBody()));
|
"一站式开发工具平台 · 文本对比 · 代码生成 · Git 同步\n"
|
||||||
welcomeDesc.getStyleClass().add(Theme.CSS_WELCOME_DESC);
|
+ "API 文档导出 · SQL 导出 · 关键词扫描 · 系统设置");
|
||||||
welcomeDesc.setTextAlignment(TextAlignment.CENTER);
|
welcomeDescription.setFont(Theme.body());
|
||||||
welcomeDesc.setMaxWidth(560);
|
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);
|
contentArea.getChildren().setAll(welcome);
|
||||||
hidePluginToolbar();
|
hidePluginToolbar();
|
||||||
|
activeNavItem = null;
|
||||||
activePluginId = null;
|
activePluginId = null;
|
||||||
currentEmbedded = null;
|
currentEmbedded = null;
|
||||||
}
|
}
|
||||||
@@ -454,12 +191,8 @@ public class MainWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 内嵌管理 ====================
|
|
||||||
|
|
||||||
public void embedPlugin(String pluginId, String icon, String name, Parent content) {
|
public void embedPlugin(String pluginId, String icon, String name, Parent content) {
|
||||||
EmbeddedEntry existing = embeddedCache.get(pluginId);
|
embeddedCache.remove(pluginId);
|
||||||
if (existing != null) { embeddedCache.remove(pluginId); }
|
|
||||||
|
|
||||||
EmbeddedEntry entry = new EmbeddedEntry(content, icon, name);
|
EmbeddedEntry entry = new EmbeddedEntry(content, icon, name);
|
||||||
embeddedCache.put(pluginId, entry);
|
embeddedCache.put(pluginId, entry);
|
||||||
switchToEmbedded(pluginId, entry);
|
switchToEmbedded(pluginId, entry);
|
||||||
@@ -469,29 +202,32 @@ public class MainWindow {
|
|||||||
if (currentEmbedded != null && currentEmbedded != entry) {
|
if (currentEmbedded != null && currentEmbedded != entry) {
|
||||||
contentArea.getChildren().remove(currentEmbedded.wrapper);
|
contentArea.getChildren().remove(currentEmbedded.wrapper);
|
||||||
}
|
}
|
||||||
this.activePluginId = pluginId;
|
activePluginId = pluginId;
|
||||||
this.currentEmbedded = entry;
|
currentEmbedded = entry;
|
||||||
|
|
||||||
if (entry.wrapper == null) {
|
if (entry.wrapper == null) {
|
||||||
entry.wrapper = new BorderPane();
|
entry.wrapper = new BorderPane();
|
||||||
entry.wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA);
|
entry.wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA);
|
||||||
entry.wrapper.setCenter(entry.content);
|
entry.wrapper.setCenter(entry.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contentArea.getChildren().contains(entry.wrapper)) {
|
if (!contentArea.getChildren().contains(entry.wrapper)) {
|
||||||
contentArea.getChildren().setAll(entry.wrapper);
|
contentArea.getChildren().setAll(entry.wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
showPluginToolbar(entry.icon, entry.title);
|
showPluginToolbar(entry.icon, entry.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void closePlugin(String pluginId) {
|
public void closePlugin(String pluginId) {
|
||||||
EmbeddedEntry entry = embeddedCache.remove(pluginId);
|
EmbeddedEntry entry = embeddedCache.remove(pluginId);
|
||||||
if (entry != null) {
|
if (entry == null) {
|
||||||
if (entry.wrapper != null) { contentArea.getChildren().remove(entry.wrapper); }
|
return;
|
||||||
if (currentEmbedded == entry) {
|
|
||||||
currentEmbedded = null; activePluginId = null; showWelcomePage();
|
|
||||||
}
|
}
|
||||||
|
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);
|
showPluginToolbar(icon, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 主题刷新(供设置窗口调用) ====================
|
|
||||||
|
|
||||||
public void refreshTheme() {
|
public void refreshTheme() {
|
||||||
Theme.applyCustomCss(stage.getScene());
|
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 {
|
private EmbeddedEntry(Parent content, String icon, String title) {
|
||||||
final Parent content;
|
this.content = content;
|
||||||
final String icon, title;
|
this.icon = icon;
|
||||||
BorderPane wrapper;
|
this.title = title;
|
||||||
|
|
||||||
EmbeddedEntry(Parent c, String ic, String t) {
|
|
||||||
content=c; icon=ic; title=t;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 导航项 ====================
|
|
||||||
|
|
||||||
public static class NavItem {
|
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 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