diff --git a/src/main/java/com/example/workutils/system/ui/MainWindow.java b/src/main/java/com/example/workutils/system/ui/MainWindow.java index 18214c6..690e381 100644 --- a/src/main/java/com/example/workutils/system/ui/MainWindow.java +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -1,159 +1,76 @@ package com.example.workutils.system.ui; import com.example.workutils.system.config.WorkUtilsProperties; -import javafx.animation.KeyFrame; -import javafx.animation.KeyValue; -import javafx.animation.Timeline; -import javafx.application.Platform; -import javafx.collections.FXCollections; +import com.example.workutils.system.ui.icon.AppIcon; +import com.example.workutils.system.ui.icon.IconFactory; +import com.example.workutils.system.ui.shell.NavigationRail; import javafx.geometry.Insets; -import javafx.geometry.Orientation; import javafx.geometry.Pos; -import javafx.scene.Cursor; import javafx.scene.Parent; import javafx.scene.Scene; -import javafx.scene.control.*; +import javafx.scene.control.Label; import javafx.scene.image.Image; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.*; -import javafx.scene.paint.Color; -import javafx.scene.shape.Circle; -import javafx.scene.shape.Rectangle; -import javafx.scene.text.Font; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; import javafx.scene.text.TextAlignment; import javafx.stage.Stage; -import javafx.util.Duration; +import org.kordamp.ikonli.javafx.FontIcon; import java.io.InputStream; -import java.util.*; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; -/** - * 工作工具集主窗口 —— AtlantaFX 重构版 - *

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