From 40f556d0e1a9dc0f0a2dc0bd69e470625218dfc6 Mon Sep 17 00:00:00 2001 From: Timi Date: Mon, 14 Jul 2025 15:08:16 +0800 Subject: [PATCH] Initial project --- .gitignore | 130 +-- .idea/.gitignore | 3 + .idea/encodings.xml | 7 + .idea/misc.xml | 14 + .idea/uiDesigner.xml | 124 +++ .idea/vcs.xml | 6 + README.md | 2 +- pom.xml | 66 ++ .../java/com/imyeyu/fx/ui/MinecraftFont.java | 84 ++ .../java/com/imyeyu/fx/ui/ScreenIdentify.java | 118 +++ src/main/java/com/imyeyu/fx/ui/TimiFXUI.java | 437 ++++++++++ .../fx/ui/components/CheckBoxPicker.java | 153 ++++ .../imyeyu/fx/ui/components/ContextMenu.java | 63 ++ .../fx/ui/components/DateTimePicker.java | 282 ++++++ .../imyeyu/fx/ui/components/EnumListCell.java | 32 + .../imyeyu/fx/ui/components/FileTreeView.java | 473 ++++++++++ .../imyeyu/fx/ui/components/IconButton.java | 208 +++++ .../imyeyu/fx/ui/components/IconPicker.java | 207 +++++ .../fx/ui/components/LabelProgressBar.java | 138 +++ .../imyeyu/fx/ui/components/Navigation.java | 243 ++++++ .../imyeyu/fx/ui/components/NumberField.java | 152 ++++ .../fx/ui/components/ProgressSlider.java | 55 ++ .../com/imyeyu/fx/ui/components/SVGIcon.java | 69 ++ .../fx/ui/components/SelectableLabel.java | 172 ++++ .../fx/ui/components/TextAreaEditor.java | 773 +++++++++++++++++ .../fx/ui/components/TextAreaEditorField.java | 173 ++++ .../imyeyu/fx/ui/components/TextFlower.java | 334 +++++++ .../imyeyu/fx/ui/components/TimePicker.java | 201 +++++ .../imyeyu/fx/ui/components/TitleLabel.java | 134 +++ .../imyeyu/fx/ui/components/ToggleIcon.java | 215 +++++ .../com/imyeyu/fx/ui/components/TrayFX.java | 395 +++++++++ .../imyeyu/fx/ui/components/VersionLabel.java | 208 +++++ .../imyeyu/fx/ui/components/XHyperlink.java | 133 +++ .../imyeyu/fx/ui/components/XPagination.java | 337 ++++++++ .../com/imyeyu/fx/ui/components/XTabPane.java | 113 +++ .../imyeyu/fx/ui/components/XTreeView.java | 45 + .../fx/ui/components/alert/AbstractAlert.java | 448 ++++++++++ .../components/alert/AbstractAlertFile.java | 307 +++++++ .../components/alert/AbstractAlertInput.java | 111 +++ .../fx/ui/components/alert/AlertButton.java | 249 ++++++ .../fx/ui/components/alert/AlertConfirm.java | 67 ++ .../alert/AlertFileBlendSelector.java | 30 + .../alert/AlertFilePathSelector.java | 38 + .../components/alert/AlertFileSelector.java | 87 ++ .../fx/ui/components/alert/AlertLoading.java | 90 ++ .../fx/ui/components/alert/AlertPassword.java | 44 + .../fx/ui/components/alert/AlertTextArea.java | 215 +++++ .../ui/components/alert/AlertTextField.java | 103 +++ .../fx/ui/components/alert/AlertTips.java | 166 ++++ .../fx/ui/components/alert/AlertType.java | 54 ++ .../fx/ui/components/alert/package-info.java | 2 + .../imyeyu/fx/ui/components/package-info.java | 2 + .../popup/AbstractPopupTipsService.java | 150 ++++ .../ui/components/popup/PopupTipsService.java | 152 ++++ .../fx/ui/components/popup/package-info.java | 2 + .../popup/tips/AbstractPopupTips.java | 123 +++ .../components/popup/tips/PopupTipsImage.java | 105 +++ .../components/popup/tips/PopupTipsLabel.java | 36 + .../components/popup/tips/package-info.java | 2 + .../ui/components/table/BindingTableCell.java | 104 +++ .../components/table/CheckBoxTableCell.java | 29 + .../components/table/TextFieldTableCell.java | 38 + .../fx/ui/components/table/package-info.java | 2 + src/main/resources/lang/timi-fx-ui/de_DE.lang | 53 ++ src/main/resources/lang/timi-fx-ui/en_US.lang | 53 ++ src/main/resources/lang/timi-fx-ui/ja_JP.lang | 53 ++ src/main/resources/lang/timi-fx-ui/ko_KR.lang | 53 ++ src/main/resources/lang/timi-fx-ui/ru_RU.lang | 53 ++ src/main/resources/lang/timi-fx-ui/zh_CN.lang | 53 ++ src/main/resources/lang/timi-fx-ui/zh_TW.lang | 53 ++ src/main/resources/timifx/MinecraftAE.ttf | Bin 0 -> 16507488 bytes .../timifx/dialog-confirmation16x.png | Bin 0 -> 485 bytes src/main/resources/timifx/dialog-error16x.png | Bin 0 -> 433 bytes .../timifx/dialog-information16x.png | Bin 0 -> 479 bytes .../timifx/dialog-warning-danger16x.png | Bin 0 -> 210 bytes .../resources/timifx/dialog-warning16x.png | Bin 0 -> 335 bytes src/main/resources/timifx/font.css | 22 + src/main/resources/timifx/icon.png | Bin 0 -> 191 bytes src/main/resources/timifx/style.css | 816 ++++++++++++++++++ .../imyeyu/fx/ui/examples/TimiFXExamples.java | 66 ++ .../imyeyu/fx/ui/examples/bean/Config.java | 34 + .../examples/component/AbstractDemoPane.java | 71 ++ .../ui/examples/component/AbstractPane.java | 50 ++ .../fx/ui/examples/component/RootLayout.java | 30 + .../examples/component/TimiVersionLabel.java | 68 ++ .../component/animation/InterpolatorPane.java | 112 +++ .../examples/component/sidebar/Sidebar.java | 116 +++ .../component/sidebar/SidebarItem.java | 160 ++++ .../com/imyeyu/fx/ui/examples/ctrl/Main.java | 67 ++ .../fx/ui/examples/service/PageService.java | 62 ++ .../imyeyu/fx/ui/examples/util/Resources.java | 32 + .../imyeyu/fx/ui/examples/view/ViewMain.java | 44 + .../fx/ui/examples/view/pages/AlertDemo.java | 225 +++++ .../view/pages/AnimationRendererDemo.java | 173 ++++ .../view/pages/BindingsConfigDemo.java | 103 +++ .../fx/ui/examples/view/pages/ExtendDemo.java | 118 +++ .../ui/examples/view/pages/PopupTipsDemo.java | 77 ++ .../ui/examples/view/pages/RunAsyncDemo.java | 77 ++ .../fx/ui/examples/view/pages/Style.java | 188 ++++ .../fx/ui/examples/view/pages/Welcome.java | 174 ++++ .../pages/animation/InterpolatorDemo.java | 132 +++ .../pages/animation/SmoothScrollDemo.java | 134 +++ .../pages/component/CheckBoxPickerDemo.java | 51 ++ .../pages/component/DateTimePickerDemo.java | 39 + .../component/EditableTableCellDemo.java | 111 +++ .../pages/component/FileTreeViewDemo.java | 69 ++ .../view/pages/component/IconButtonDemo.java | 50 ++ .../view/pages/component/IconPickerDemo.java | 47 + .../pages/component/LabelProgressBarDemo.java | 63 ++ .../view/pages/component/NavigationDemo.java | 79 ++ .../view/pages/component/NumberFieldDemo.java | 35 + .../pages/component/ProgressSliderDemo.java | 41 + .../pages/component/SelectableLabelDemo.java | 48 ++ .../pages/component/TextAreaEditorDemo.java | 63 ++ .../view/pages/component/TitleLabelDemo.java | 43 + .../view/pages/component/ToggleIconDemo.java | 52 ++ .../view/pages/component/XPaginationDemo.java | 67 ++ .../view/pages/component/XTabPaneDemo.java | 45 + .../view/pages/component/XTreeViewDemo.java | 31 + .../view/pages/other/DraggableNodeDemo.java | 69 ++ .../view/pages/other/DraggableWindowDemo.java | 103 +++ .../view/pages/other/ScreenFXDemo.java | 44 + .../examples/view/pages/other/TrayFXDemo.java | 119 +++ src/test/resources/TimiFXExamples.yaml | 5 + src/test/resources/lang/de_DE.lang | 191 ++++ src/test/resources/lang/en_US.lang | 177 ++++ src/test/resources/lang/ja_JP.lang | 177 ++++ src/test/resources/lang/ko_KR.lang | 177 ++++ src/test/resources/lang/ru_RU.lang | 177 ++++ src/test/resources/lang/zh_CN.lang | 191 ++++ src/test/resources/lang/zh_TW.lang | 177 ++++ src/test/resources/logback.xml | 21 + src/test/resources/splash-screen.png | Bin 0 -> 736 bytes 133 files changed, 15044 insertions(+), 95 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/encodings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/uiDesigner.xml create mode 100644 .idea/vcs.xml create mode 100644 pom.xml create mode 100644 src/main/java/com/imyeyu/fx/ui/MinecraftFont.java create mode 100644 src/main/java/com/imyeyu/fx/ui/ScreenIdentify.java create mode 100644 src/main/java/com/imyeyu/fx/ui/TimiFXUI.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/CheckBoxPicker.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/ContextMenu.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/DateTimePicker.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/EnumListCell.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/FileTreeView.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/IconButton.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/IconPicker.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/LabelProgressBar.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/Navigation.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/NumberField.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/ProgressSlider.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/SVGIcon.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/SelectableLabel.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/TextAreaEditor.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/TextAreaEditorField.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/TextFlower.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/TimePicker.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/TitleLabel.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/ToggleIcon.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/TrayFX.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/VersionLabel.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/XHyperlink.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/XPagination.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/XTabPane.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/XTreeView.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlert.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertFile.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertInput.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertButton.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertConfirm.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileBlendSelector.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertFilePathSelector.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileSelector.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertLoading.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertPassword.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertTextArea.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertTextField.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertTips.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/AlertType.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/alert/package-info.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/package-info.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/AbstractPopupTipsService.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/PopupTipsService.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/package-info.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/tips/AbstractPopupTips.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/tips/PopupTipsImage.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/tips/PopupTipsLabel.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/popup/tips/package-info.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/table/BindingTableCell.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/table/CheckBoxTableCell.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/table/TextFieldTableCell.java create mode 100644 src/main/java/com/imyeyu/fx/ui/components/table/package-info.java create mode 100644 src/main/resources/lang/timi-fx-ui/de_DE.lang create mode 100644 src/main/resources/lang/timi-fx-ui/en_US.lang create mode 100644 src/main/resources/lang/timi-fx-ui/ja_JP.lang create mode 100644 src/main/resources/lang/timi-fx-ui/ko_KR.lang create mode 100644 src/main/resources/lang/timi-fx-ui/ru_RU.lang create mode 100644 src/main/resources/lang/timi-fx-ui/zh_CN.lang create mode 100644 src/main/resources/lang/timi-fx-ui/zh_TW.lang create mode 100644 src/main/resources/timifx/MinecraftAE.ttf create mode 100644 src/main/resources/timifx/dialog-confirmation16x.png create mode 100644 src/main/resources/timifx/dialog-error16x.png create mode 100644 src/main/resources/timifx/dialog-information16x.png create mode 100644 src/main/resources/timifx/dialog-warning-danger16x.png create mode 100644 src/main/resources/timifx/dialog-warning16x.png create mode 100644 src/main/resources/timifx/font.css create mode 100644 src/main/resources/timifx/icon.png create mode 100644 src/main/resources/timifx/style.css create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/TimiFXExamples.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/bean/Config.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/AbstractDemoPane.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/AbstractPane.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/RootLayout.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/TimiVersionLabel.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/animation/InterpolatorPane.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/sidebar/Sidebar.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/component/sidebar/SidebarItem.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/ctrl/Main.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/service/PageService.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/util/Resources.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/ViewMain.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/AlertDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/AnimationRendererDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/BindingsConfigDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/ExtendDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/PopupTipsDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/RunAsyncDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/Style.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/Welcome.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/animation/InterpolatorDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/animation/SmoothScrollDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/CheckBoxPickerDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/DateTimePickerDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/EditableTableCellDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/FileTreeViewDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/IconButtonDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/IconPickerDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/LabelProgressBarDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/NavigationDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/NumberFieldDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/ProgressSliderDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/SelectableLabelDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/TextAreaEditorDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/TitleLabelDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/ToggleIconDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/XPaginationDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/XTabPaneDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/XTreeViewDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/other/DraggableNodeDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/other/DraggableWindowDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/other/ScreenFXDemo.java create mode 100644 src/test/java/com/imyeyu/fx/ui/examples/view/pages/other/TrayFXDemo.java create mode 100644 src/test/resources/TimiFXExamples.yaml create mode 100644 src/test/resources/lang/de_DE.lang create mode 100644 src/test/resources/lang/en_US.lang create mode 100644 src/test/resources/lang/ja_JP.lang create mode 100644 src/test/resources/lang/ko_KR.lang create mode 100644 src/test/resources/lang/ru_RU.lang create mode 100644 src/test/resources/lang/zh_CN.lang create mode 100644 src/test/resources/lang/zh_TW.lang create mode 100644 src/test/resources/logback.xml create mode 100644 src/test/resources/splash-screen.png diff --git a/.gitignore b/.gitignore index c6d98d1..cbc4d53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,98 +1,40 @@ -# ---> JetBrains -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -# ---> Maven target/ -pom.xml.tag -pom.xml.releaseBackup -pom.xml.versionsBackup -pom.xml.next -release.properties -dependency-reduced-pom.xml -buildNumber.properties -.mvn/timing.properties -# https://github.com/takari/maven-wrapper#usage-without-binary-jar -.mvn/wrapper/maven-wrapper.jar +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ -# Eclipse m2e generated files -# Eclipse Core -.project -# JDT-specific (Eclipse Java Development Tools) +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated .classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +/TimiFXExamples.yaml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..106fade --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..fdc35ea --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 2aba289..5f61414 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # timi-fx-ui -JavaFX 二次封装组件库 \ No newline at end of file +JavaFX 二次封装组件库,包含演示程序 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7d15fb9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + com.imyeyu.fx.ui + timi-fx-ui + 0.0.1 + + + true + 21 + 21 + UTF-8 + + + + + com.imyeyu.fx + timi-fx + 0.0.1 + + + com.imyeyu.fx.icon + timi-fx-icon + 0.0.1 + + + com.imyeyu.lang + timi-lang + 0.0.1 + + + + org.projectlombok + lombok + 1.18.36 + test + + + com.imyeyu.network + timi-network + 0.0.1 + test + + + com.imyeyu.config + timi-config + 0.0.1 + test + + + com.google.code.gson + gson + 2.11.0 + test + + + com.imyeyu.inject + timi-inject + 0.0.1 + test + + + diff --git a/src/main/java/com/imyeyu/fx/ui/MinecraftFont.java b/src/main/java/com/imyeyu/fx/ui/MinecraftFont.java new file mode 100644 index 0000000..4273e60 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/MinecraftFont.java @@ -0,0 +1,84 @@ +package com.imyeyu.fx.ui; + +import javafx.scene.Node; +import javafx.scene.text.Font; + +/** + * Minecraft 字体,此字体为点阵字体,不会模糊渲染,在 16,32,64,128 像素时最为清晰 + * + * @author 夜雨 + * @since 2021-04-14 00:11 + */ +public class MinecraftFont implements TimiFXUI { + + /** 小号,16 像素 */ + public static int X16 = 16; + + /** 中号,32 像素 */ + public static int X32 = 32; + + /** 大号,64 像素 */ + public static int X64 = 64; + + /** 特大号,128 像素 */ + public static int X128 = 128; + + private static Font F_X16, F_X32, F_X64, F_X128; + private static final String NAME = "MinecraftAE.ttf"; + + /** + * 通过 CSS 修改字号,请置于组件样式修改的最后 + * + * @param node 组件 + * @param size 字号,单位:像素 + */ + public static void css(Node node, int size) { + node.setStyle(node.getStyle() + "; -fx-font-size: " + size); + } + + /** + * 获取小号字体 X16 + * + * @return 小号字体 + */ + public static Font X16() { + return F_X16 == null ? F_X16 = build(X16) : F_X16; + } + + /** + * 获取中号字体 X32 + * + * @return 中号字体 + */ + public static Font X32() { + return F_X32 == null ? F_X32 = build(X32) : F_X32; + } + + /** + * 获取大号字体 X64 + * + * @return 大号字体 + */ + public static Font X64() { + return F_X64 == null ? F_X64 = build(X64) : F_X64; + } + + /** + * 获取特大号字体 X128 + * + * @return 特大号字体 + */ + public static Font X128() { + return F_X128 == null ? F_X128 = build(X128) : F_X128; + } + + /** + * 构建字体,Minecraft 字体在 16 的公倍数时渲染最佳 + * + * @param size 字号 + * @return 字体 + */ + public static Font build(int size) { + return Font.loadFont(MinecraftFont.class.getResourceAsStream(RESOURCE + NAME), size); + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/ui/ScreenIdentify.java b/src/main/java/com/imyeyu/fx/ui/ScreenIdentify.java new file mode 100644 index 0000000..ac07492 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/ScreenIdentify.java @@ -0,0 +1,118 @@ +package com.imyeyu.fx.ui; + +import com.imyeyu.fx.utils.ScreenFX; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.geometry.Rectangle2D; +import javafx.scene.control.Label; +import javafx.stage.Popup; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import java.util.List; + +/** + * @author 夜雨 + * @version 2024-04-13 22:40 + */ +public class ScreenIdentify extends Stage implements TimiFXUI { + + private final BooleanProperty showingIdentify; + private final ObservableList showingIdentifyList; + + public ScreenIdentify() { + + showingIdentifyList = FXCollections.observableArrayList(); + + showingIdentify = new SimpleBooleanProperty(false); + showingIdentify.bind(Bindings.isEmpty(showingIdentifyList)); + + initStyle(StageStyle.UTILITY); + setOpacity(0); + setWidth(10); + setHeight(10); + setX(-20); + setY(-20); + + // ---------- 事件 ---------- + + showingIdentifyList.addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasAdded()) { + List list = c.getAddedSubList(); + for (int i = 0; i < list.size(); i++) { + Rectangle2D r2d = list.get(i).screen.getBounds(); + list.get(i).show(this, r2d.getMinX() + 80, r2d.getMinY() + 80); + } + } + if (c.wasRemoved()) { + List list = c.getRemoved(); + for (int i = 0; i < list.size(); i++) { + list.get(i).hide(); + } + } + } + }); + } + + /** 显示标识 */ + public void showIdentify() { + show(); + + List screens = ScreenFX.SCREENS; + for (int i = 0; i < screens.size(); i++) { + showingIdentifyList.add(new Identify(screens.get(i), i)); + } + } + + /** 隐藏标识 */ + public void hideIdentify() { + showingIdentifyList.clear(); + hide(); + } + + /** @return true 为正在显示标识 */ + public boolean isShowingIdentify() { + return showingIdentify.get(); + } + + /** @return 正在显示标识监听 */ + public ReadOnlyBooleanProperty showingIdentify() { + return showingIdentify; + } + + /** + * 屏幕标识 + * + * @author 夜雨 + * @since 2022-02-17 15:42 + */ + private static class Identify extends Popup implements TimiFXUI, TimiFXUI.Colorful { + + /** 所属屏幕 */ + final Screen screen; + + public Identify(Screen screen, int i) { + this.screen = screen; + + Label text = new Label(String.valueOf(i)); + text.setTextFill(WHITE); + text.setAlignment(Pos.CENTER); + text.prefHeightProperty().bind(text.widthProperty()); + text.prefWidthProperty().bind(text.heightProperty()); + MinecraftFont.css(text, 256); + + getContent().setAll(text); + getScene().setFill(BLACK); + getScene().getStylesheets().addAll(CSS_STYLE, CSS_FONT); + sizeToScene(); + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/TimiFXUI.java b/src/main/java/com/imyeyu/fx/ui/TimiFXUI.java new file mode 100644 index 0000000..56d7803 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/TimiFXUI.java @@ -0,0 +1,437 @@ +package com.imyeyu.fx.ui; + +import com.imyeyu.fx.utils.BgFill; +import com.imyeyu.fx.utils.BorderStroke; +import com.imyeyu.lang.multi.ResourcesMultilingual; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.TableColumn; +import javafx.scene.effect.DropShadow; +import javafx.scene.layout.Background; +import javafx.scene.layout.Border; +import javafx.scene.paint.Color; + +/** + * @author 夜雨 + * @version 2024-04-13 14:18 + */ +public interface TimiFXUI { + + /** 静态资源路径 */ + String RESOURCE = "/timifx/"; + + /** 样式文件 */ + String CSS_STYLE = RESOURCE + "style.css"; + + /** 全局字体替换 */ + String CSS_FONT = RESOURCE + "font.css"; + + ResourcesMultilingual MULTILINGUAL = new ResourcesMultilingual(); + + /** + * + * + * @author 夜雨 + * @since 2024-04-13 11:51 + */ + interface Colorful { + + /** 白色 */ + Color WHITE = Color.valueOf("#FFF"); + + /** 品红 */ + Color RED = Color.valueOf("#F30"); + + /** 褐色 */ + Color BROWN = Color.valueOf("#A67D7B"); + + /** 黑色 */ + Color BLACK = Color.valueOf("#000"); + + /** 橙色 */ + Color ORANGE = Color.valueOf("#F60"); + + /** 黄色 */ + Color YELLOW = Color.valueOf("#FF0"); + + /** 绿色 */ + Color GREEN = Color.valueOf("#393"); + + /** 深绿 */ + Color DARK_GREEN = Color.valueOf("#373"); + + /** 灰色 */ + Color GRAY = Color.valueOf("#666"); + + /** 天蓝 */ + Color BLUE = Color.valueOf("#008DCB"); + + /** 浅蓝 */ + Color LIGHT_BLUE = Color.valueOf("#DDEAF0"); + + /** 灰白(程序默认底色 F4F4F4) */ + Color GRAY_WHITE = Color.valueOf("#F4F4F4"); + + /** 亮灰 */ + Color LIGHT_GRAY = Color.valueOf("#B5B5B5"); + + /** 深灰 */ + Color DARK_GRAY = Color.valueOf("#333"); + + /** 少女粉 */ + Color PINK = Color.valueOf("#FF7A9B"); + + /** 透明 */ + Color TRANSPARENT = Color.TRANSPARENT; + + // ---------- 聚焦颜色 ---------- + + /** 聚焦颜色 - 默认 */ + Color FOCUSED_DEFAULT = Color.valueOf("#177CB0"); + + /** 聚焦颜色 - 亮 */ + Color FOCUSED_LIGHT = Color.valueOf("#55B0DF"); + + /** 聚焦颜色 - 暗 */ + Color FOCUSED_DARK = Color.valueOf("#0B6C9E"); + + // ---------- 图标 ---------- + + + /** 图标颜色 */ + Color ICON = DARK_GRAY; + + /** 图标禁用颜色 */ + Color ICON_DISABLED = Color.valueOf("#939393"); + + /** 图标指向颜色 */ + Color ICON_HOVER = LIGHT_GRAY; + + // ---------- 边框 ---------- + + /** 默认边框颜色 */ + Color BORDER = LIGHT_GRAY; + } + + /** + * @author 夜雨 + * @version 2024-04-13 11:53 + */ + interface Stroke { + + /** 透明边框 */ + Border TP = new BorderStroke(Colorful.TRANSPARENT).build(); + + /** 默认边框 */ + Border DEFAULT = new BorderStroke(Colorful.BORDER).build(); + + /** 禁用边框 */ + Border DISABLE = new BorderStroke("#E1E1E1").build(); + + /** 聚焦边框 */ + Border FOCUSED = new BorderStroke(Colorful.BORDER).build(); + + /** 上边框 */ + Border TOP = new BorderStroke(Colorful.BORDER).top().build(); + + /** 左边框 */ + Border LEFT = new BorderStroke(Colorful.BORDER).left().build(); + + /** 右边框 */ + Border RIGHT = new BorderStroke(Colorful.BORDER).right().build(); + + /** 下边框 */ + Border BOTTOM = new BorderStroke(Colorful.BORDER).bottom().build(); + + /** 除了上边框 */ + Border EX_TOP = new BorderStroke(Colorful.BORDER).exTop().build(); + + /** 除了左边框 */ + Border EX_LEFT = new BorderStroke(Colorful.BORDER).exLeft().build(); + + /** 除了右边框 */ + Border EX_RIGHT = new BorderStroke(Colorful.BORDER).exRight().build(); + + /** 除了下边框 */ + Border EX_BOTTOM = new BorderStroke(Colorful.BORDER).exBottom().build(); + + /** 上右边框 */ + Border TR = new BorderStroke(Colorful.BORDER).width(1, 1, 0, 0).build(); + + /** 右下边框 */ + Border RB = new BorderStroke(Colorful.BORDER).width(0, 1, 1, 0).build(); + + /** 左下边框 */ + Border BL = new BorderStroke(Colorful.BORDER).width(0, 0, 1, 1).build(); + + /** 左上边框 */ + Border LT = new BorderStroke(Colorful.BORDER).width(1, 0, 0, 1).build(); + + /** 水平边框,边的方向而非位置 */ + Border H = new BorderStroke(Colorful.BORDER).width(1, 0).build(); + + /** 垂直边框,边的方向而非位置 */ + Border V = new BorderStroke(Colorful.BORDER).width(0, 1).build(); + } + + /** + * @author 夜雨 + * @version 2024-04-13 11:53 + */ + interface CSS { + + /** CSS Minecraft AE 字体 */ + String MINECRAFT = "minecraft-ae"; + + // ---------- CSS 边框 ---------- + + /** CSS 所有边框 */ + String BORDER_ALL = "border-all"; + + /** CSS 无边框 */ + String BORDER_N = "border-n"; + + /** CSS 上边框 */ + String BORDER_T = "border-t"; + + /** CSS 右边框 */ + String BORDER_R = "border-r"; + + /** CSS 下边框 */ + String BORDER_B = "border-b"; + + /** CSS 左边框 */ + String BORDER_L = "border-l"; + + /** CSS 上右边框 */ + String BORDER_TR = "border-tr"; + + /** CSS 右下边框 */ + String BORDER_RB = "border-rb"; + + /** CSS 左下边框 */ + String BORDER_BL = "border-bl"; + + /** CSS 左上边框 */ + String BORDER_LT = "border-lt"; + + /** CSS 上下右边框 */ + String BORDER_TRB = "border-trb"; + + /** CSS 上下左边框 */ + String BORDER_BLT = "border-blt"; + + /** CSS 左右下边框 */ + String BORDER_RBL = "border-rbl"; + + /** CSS 左右上边框 */ + String BORDER_LTR = "border-ltr"; + + /** CSS 上下边框 */ + String BORDER_TB = "border-tb"; + + /** CSS 左右边框 */ + String BORDER_LR = "border-lr"; + + // ---------- CSS 内边距 ---------- + + /** CSS 无内边距 */ + String PADDING_N = "padding-n"; + + // ---------- CSS 背景 ---------- + + /** CSS 透明背景 */ + String BG_TP = "bg-tp"; + + /** CSS 纯白背景 */ + String BG_WHITE = "bg-white"; + + /** CSS 纯黑背景 */ + String BG_BLACK = "bg-black"; + + /** CSS 默认背景 */ + String BG_DEFAULT = "bg-default"; + + /** CSS 按钮背景 */ + String BG_BUTTON = "bg-button"; + + /** CSS 按钮背景(只有背景,没有事件样式) */ + String BG_BUTTON_STATIC = "bg-button-static"; + + // ---------- CSS 其他 ---------- + + /** CSS 光标指向透明度 */ + String HOVER_OPACITY = "hover-opacity"; + + /** CSS 滚动面板的滚动条左侧边框 */ + String SP_BORDER = "sp-border"; + + /** CSS 可编辑表格 */ + String EDITABLE_TABLE = "editable-table"; + } + + /** + * @author 夜雨 + * @version 2024-04-13 11:53 + */ + interface BG { + + /** FX 默认背景(#F4F4F4) */ + Background DEFAULT = new BgFill(Colorful.GRAY_WHITE).build(); + + /** 灰色背景 */ + Background GRAY = new BgFill(Colorful.GRAY).build(); + + /** 亮灰背景 */ + Background LIGHT_GRAY = new BgFill(Colorful.LIGHT_GRAY).build(); + + /** 纯黑背景 */ + Background BLACK = new BgFill(Colorful.BLACK).build(); + + /** 纯白背景 */ + Background WHITE = new BgFill(Colorful.WHITE).build(); + + /** 透明背景 */ + Background TRANSPARENT = new BgFill(Colorful.TRANSPARENT).build(); + + /** 淡蓝背景 */ + Background LIGHT_BLUE = new BgFill(Colorful.LIGHT_BLUE).build(); + + /** 聚焦色背景 */ + Background FOCUSED = new BgFill(Colorful.FOCUSED_DEFAULT).build(); + + /** 指向背景,通常用于提示组件尺寸响应拖动 */ + Background HOVER = new BgFill("#0007").build(); + + /** 渐变的标题背景 */ + Background TITLE = new BgFill("#DDD", "#F4F4F400").toRight().build(); + + /** 填充的标题背景 */ + Background TITLE_FILL = new BgFill("#DDD").build(); + } + + /** + * @author 夜雨 + * @version 2024-04-13 11:55 + */ + interface Shadow { + + Insets PADDING = new Insets(8); + + /** 窗体投影 */ + DropShadow POPUP = new DropShadow() {{ + setRadius(8); + setOffsetX(0); + setOffsetY(0); + setSpread(.05); + setColor(Color.valueOf("#3337")); + }}; + + /** 图片投影 */ + DropShadow IMAGE = new DropShadow() {{ + setRadius(6); + setOffsetX(0); + setOffsetY(0); + setSpread(.05); + setColor(Color.valueOf("#3336")); + }}; + + /** 下边投影 */ + DropShadow DOWN = new DropShadow() {{ + setRadius(6); + setOffsetX(0); + setOffsetY(2); + setSpread(.05); + setColor(Color.valueOf("#3334")); + }}; + } + + /** + * 快速构建菜单分割线 + * + * @return 菜单分割线 + */ + static SeparatorMenuItem sep() { + return new SeparatorMenuItem(); + } + + /** + * 构建通用灰色标签 + * + * @return 灰色标签 + */ + static Label label() { + return label(""); + } + + /** + * 构建通用灰色标签 + * + * @param text 标签文本 + * @return 灰色标签 + */ + static Label label(String text) { + Label label = new Label(text); + label.setTextFill(Colorful.GRAY); + return label; + } + + /** + * 构建通用标题标签 + * + * @param text 标题文本 + * @return 标签 + */ + static Label title(String text) { + return title(text, Border.EMPTY, null); + } + + /** + * 构建通用标题标签 + * + * @param text 标题文本 + * @param border 边框 + * @return 标签 + */ + static Label title(String text, Border border) { + return title(text, border, null); + } + + /** + * 构建通用标题标签 + * + * @param text 标题文本 + * @param border 边框 + * @param icon 图标 + * @return 标签 + */ + static Label title(String text, Border border, Node icon) { + Label label = new Label(text, icon); + label.setBorder(border); + label.setPadding(new Insets(4, 6, 4, 6)); + label.setMaxWidth(Double.MAX_VALUE); + label.setBackground(BG.TITLE); + return label; + } + + /** + * 构建空的表格列,通常用于触发事件 + * + * @param width 预设宽度 + * @param sClass 表格数据类对象 + * @param tClass 列数据类对象 + * @param 表格数据类 + * @param 列数据类 + * @return 列对象 + */ + static TableColumn emptyTableColumn(double width, Class sClass, Class tClass) { + TableColumn col = new TableColumn<>(); + col.setSortable(false); + col.setResizable(false); + col.setReorderable(false); + col.setPrefWidth(width); + return col; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/CheckBoxPicker.java b/src/main/java/com/imyeyu/fx/ui/components/CheckBoxPicker.java new file mode 100644 index 0000000..d6316d4 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/CheckBoxPicker.java @@ -0,0 +1,153 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.bean.CallbackArgReturn; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.CheckBox; +import javafx.scene.control.TextField; +import javafx.scene.layout.TilePane; +import javafx.stage.Popup; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 多选选择器,文本框弹出复选框组件进行多项选择 + * + * @author 夜雨 + * @since 2021-12-29 17:05 + */ +public class CheckBoxPicker extends TextField implements TimiFXUI { + + private final ObservableList items; + private final CheckBoxListPopup checkBoxListPopup; + + /** 默认构造器 */ + public CheckBoxPicker() { + items = FXCollections.observableList(new ArrayList<>()); + + checkBoxListPopup = new CheckBoxListPopup<>(items); + + setEditable(false); + + // ---------- 事件 ---------- + + setOnMouseClicked(e -> { + Bounds b = localToScreen(getLayoutBounds()); + checkBoxListPopup.show(this, b.getMinX() - 5, b.getMaxY() - 6); + }); + + checkBoxListPopup.root.prefWidthProperty().bind(widthProperty()); + } + + /** + * 设置复选框工厂,工厂入参数据对象,返回复选框组件 + * + * @param factory 复选框工厂 + */ + public void setCheckBoxFactory(CallbackArgReturn factory) { + checkBoxListPopup.checkBoxFactory = factory; + } + + /** + * 获取已选项 + * + * @return 已选项 + */ + public ObservableList getSelectedItems() { + return checkBoxListPopup.selectedItems; + } + + /** + * 获取数据列表 + * + * @return 数据列表 + */ + public ObservableList getItems() { + return items; + } + + /** + * 复选框列表弹窗 + * + * @param 数据类型 + * @author 夜雨 + * @since 2021-12-29 19:40 + */ + private static class CheckBoxListPopup extends Popup implements TimiFXUI { + + private static final Insets PADDING = new Insets(6, 8, 6, 8); + + private final TilePane root; + private final Map cache; // 数据映射缓存 + + final ObservableList selectedItems; + CallbackArgReturn checkBoxFactory; + + /** + * 默认构造 + * + * @param items 数据列表 + */ + public CheckBoxListPopup(ObservableList items) { + cache = new HashMap<>(); + selectedItems = FXCollections.observableList(new ArrayList<>()); + + root = new TilePane(); + root.setHgap(8); + root.setEffect(Shadow.POPUP); + root.setBorder(Stroke.DEFAULT); + root.setPadding(PADDING); + root.setMinWidth(300); + root.setBackground(BG.DEFAULT); + root.setTileAlignment(Pos.CENTER_LEFT); + + setAutoHide(true); + getContent().add(root); + + // ---------- 事件 ---------- + + // 列表更新 + items.addListener((ListChangeListener) c -> { + if (c.next()) { + if (c.wasAdded()) { + // 添加 + List list = c.getAddedSubList(); + for (T t : list) { + CheckBox box; + if (checkBoxFactory != null) { + box = checkBoxFactory.handler(t); + } else { + box = new CheckBox(t.toString()); + } + box.selectedProperty().addListener((obs, o, isSelected) -> { + if (isSelected) { + selectedItems.add(t); + } else { + selectedItems.remove(t); + } + }); + cache.put(t, box); + root.getChildren().add(box); + } + return; + } + if (c.wasRemoved()) { + // 移除 + List list = c.getRemoved(); + for (int i = 0; i < list.size(); i++) { + root.getChildren().remove(cache.get(list.get(i))); + } + } + } + }); + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/ContextMenu.java b/src/main/java/com/imyeyu/fx/ui/components/ContextMenu.java new file mode 100644 index 0000000..85727f3 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/ContextMenu.java @@ -0,0 +1,63 @@ +package com.imyeyu.fx.ui.components; + +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; + +import java.util.List; + +/** + * 支持最小尺寸的菜单,默认 90 + * + * @author 夜雨 + * @since 2023-03-10 11:14 + */ +public class ContextMenu extends javafx.scene.control.ContextMenu { + + /** 当菜单项 {@link Menu#getProperties()} 携带此标记时,该菜单不继承最小宽度属性 */ + public static final String NOT_EXTENDS_FLAG = "NOT_EXTENDS_FLAG"; + + private static final String STYLE_TEMPLATE = "-fx-min-width: %s; -fx-pref-width: %s"; + + /** + * 标准构造 + * + * @param items 菜单项 + */ + public ContextMenu(MenuItem... items) { + super(items); + + getItems().addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasAdded()) { + updateMinWidth(getItems()); + } + } + }); + minWidthProperty().addListener((obs, o, n) -> updateMinWidth(getItems())); + setMinWidth(90); + } + + private void updateMinWidth(List items) { + for (int i = 0; i < items.size(); i++) { + if (items.get(i) instanceof Menu menu) { + if (!menu.getProperties().containsKey(NOT_EXTENDS_FLAG)) { + boolean isItemsMenu = false; // 为 true 时表示子菜单是一般菜单项,继续应用最小宽度 + ObservableList subItems = menu.getItems(); + for (int j = 0; j < subItems.size(); j++) { + if (subItems.get(j).getClass().equals(MenuItem.class)) { + isItemsMenu = true; + break; + } + } + if (isItemsMenu) { + updateMinWidth(menu.getItems()); + } + } + } else { + items.get(i).setStyle(STYLE_TEMPLATE.formatted(getMinWidth(), getMinWidth())); + } + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/DateTimePicker.java b/src/main/java/com/imyeyu/fx/ui/components/DateTimePicker.java new file mode 100644 index 0000000..a20a5a7 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/DateTimePicker.java @@ -0,0 +1,282 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.bean.Callback; +import com.imyeyu.utils.Time; +import com.sun.javafx.scene.control.DatePickerContent; +import javafx.beans.property.LongProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.event.EventHandler; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.DatePicker; +import javafx.scene.control.ListView; +import javafx.scene.control.Skin; +import javafx.scene.control.skin.DatePickerSkin; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; +import javafx.util.StringConverter; + +import java.time.LocalDate; +import java.time.LocalTime; + +/** + * 详细时间选择器 + * + * @author 夜雨 + * @since 2021-12-27 01:02 + */ +public class DateTimePicker extends Region implements TimiFXUI, TimiFXUI.Colorful { + + private static final String STYLE_CLASS = "date-time-picker"; + + /** 选择器 */ + private final DatePicker datePicker; + + /** 当前值 */ + private final LongProperty value; + + private final BorderPane timePane; + private final ListView hour, minute, second; + + /** 默认构造器 */ + public DateTimePicker() { + value = new SimpleLongProperty(-1); + + // 时间选择 + hour = new ListView<>(); + minute = new ListView<>(); + second = new ListView<>(); + + hour.getStyleClass().add(CSS.BORDER_RB); + minute.getStyleClass().add(CSS.BORDER_RB); + second.getStyleClass().add(CSS.BORDER_B); + + GridPane hmsPane = new GridPane(); + hmsPane.addRow(0, hour, minute, second); + + Button now = new Button(TimiFXUI.MULTILINGUAL.text("now_tick")); + now.getStyleClass().add(CSS.BORDER_L); + + timePane = new BorderPane(hmsPane); + timePane.setBorder(Stroke.EX_LEFT); + timePane.setEffect(Shadow.IMAGE); + timePane.setPrefWidth(140); + timePane.setBackground(BG.DEFAULT); + timePane.setTranslateY(-1); + timePane.setBottom(now); + BorderPane.setAlignment(now, Pos.CENTER_RIGHT); + + // 日期组件降权 + datePicker = new DatePicker() { + + @Override + protected Skin createDefaultSkin() { + DatePickerSkin skin = new DatePickerSkin(this) { + + private BorderPane root; + + @Override + public Node getPopupContent() { + Node popupContent = super.getPopupContent(); + if (popupContent instanceof DatePickerContent content) { + if (root == null) { + // 插入时间选择 + timePane.prefHeightProperty().bind(content.heightProperty()); + + root = new BorderPane(); + root.setCenter(popupContent); + root.setRight(timePane); + } + } + return root; + } + + @Override + public void hide() { + // 失焦关闭而非选择后关闭 + } + }; + IconButton clear = new IconButton(TimiFXIcon.fromName("FAIL", GRAY)); + clear.getStyleClass().add(CSS.BORDER_ALL); + clear.translateXProperty().bind(widthProperty().subtract(40)); + clear.setTranslateY(15); + clear.visibleProperty().bind(valueProperty().isNotNull()); + clear.setOnAction(e -> clear()); + skin.getChildren().add(clear); + return skin; + } + }; + datePicker.setEditable(false); + datePicker.prefWidthProperty().bind(widthProperty()); + datePicker.prefHeightProperty().bind(heightProperty()); + datePicker.getEditor().setCursor(Cursor.DEFAULT); + datePicker.setConverter(new StringConverter<>() { + + @Override + public String toString(LocalDate object) { + Long unixTime = value.getValue(); + if (object == null || unixTime == null) { + return ""; + } + return Time.toDateTime(unixTime); + } + + @Override + public LocalDate fromString(String string) { + return null; + } + }); + datePicker.setOnShown(e -> { + hour.scrollTo(hour.getSelectionModel().getSelectedIndex() - 3); + minute.scrollTo(minute.getSelectionModel().getSelectedIndex() - 3); + second.scrollTo(second.getSelectionModel().getSelectedIndex() - 3); + }); + + getStyleClass().add(STYLE_CLASS); + setPrefWidth(200); + getChildren().add(datePicker); + + // ---------- 事件 ---------- + + // 时间选择数据 + for (int i = 0; i < 24; i++) { + hour.getItems().add(String.format("%02d", i)); + } + for (int i = 0; i < 60; i++) { + minute.getItems().add(String.format("%02d", i)); + } + second.getItems().addAll(minute.getItems()); + + // 时间滚动居中 + hour.getSelectionModel().select(0); + minute.getSelectionModel().select(0); + second.getSelectionModel().select(0); + EventHandler middleScroll = e -> { + if (e.getSource() instanceof ListView list) { + if (e.getDeltaY() < 0) { + if (list.getSelectionModel().getSelectedIndex() < list.getItems().size() - 1) { + list.getSelectionModel().selectNext(); + } + } else { + if (0 < list.getSelectionModel().getSelectedIndex()) { + list.getSelectionModel().selectPrevious(); + } + } + list.scrollTo(list.getSelectionModel().getSelectedIndex() - 3); + e.consume(); + } + }; + hour.addEventFilter(ScrollEvent.SCROLL, middleScroll); + minute.addEventFilter(ScrollEvent.SCROLL, middleScroll); + second.addEventFilter(ScrollEvent.SCROLL, middleScroll); + + TimiFX.hoverFocus(hour); + TimiFX.hoverFocus(minute); + TimiFX.hoverFocus(second); + + // 此刻 + now.setOnAction(e -> setValue(Time.now())); + + // ---------- 解析 ---------- + + Callback parseDate = () -> { + LocalDate date = datePicker.getValue(); + if (date == null) { + return; + } + + int h = hour.getSelectionModel().getSelectedIndex(); + int m = minute.getSelectionModel().getSelectedIndex(); + int s = second.getSelectionModel().getSelectedIndex(); + + value.set(Time.fromLocalDateTime(date.atTime(LocalTime.of(h, m, s)))); + + datePicker.getEditor().setText(Time.toDateTime(value.get())); + }; + datePicker.valueProperty().addListener((obs, o, newDate) -> parseDate.handler()); + + Callback parseTime = () -> { + LocalDate date = datePicker.getValue(); + if (date == null) { + datePicker.setValue(LocalDate.now()); + } + parseDate.handler(); + }; + hour.getSelectionModel().selectedIndexProperty().addListener((obs, o, n) -> parseTime.handler()); + minute.getSelectionModel().selectedIndexProperty().addListener((obs, o, n) -> parseTime.handler()); + second.getSelectionModel().selectedIndexProperty().addListener((obs, o, n) -> parseTime.handler()); + } + + /** 清除值 */ + public void clear() { + hour.getSelectionModel().select(0); + minute.getSelectionModel().select(0); + second.getSelectionModel().select(0); + hour.scrollTo(0); + minute.scrollTo(0); + second.scrollTo(0); + + datePicker.setValue(null); + value.setValue(-1); + } + + /** + * 设置选择时间戳 + * + * @param unixTime 选择时间戳 + */ + public void setValue(Long unixTime) { + if (unixTime == null || unixTime < 0) { + clear(); + return; + } + this.value.set(unixTime); + + LocalDate date = Time.toLocalDateTime(unixTime).toLocalDate(); + datePicker.setValue(date); + + int s = (int) ((unixTime - Time.fromLocalDate(date)) / 1000); + int h = s / 60 / 60; + hour.getSelectionModel().select(h); + minute.getSelectionModel().select(s / 60 - h * 60); + second.getSelectionModel().select(s % 60); + + hour.scrollTo(hour.getSelectionModel().getSelectedIndex() - 3); + minute.scrollTo(minute.getSelectionModel().getSelectedIndex() - 3); + second.scrollTo(second.getSelectionModel().getSelectedIndex() - 3); + } + + /** + * 获取选择时间戳 + * + * @return 选择时间戳 + */ + public Long getValue() { + return -1 < value.getValue() ? value.getValue() : null; + } + + /** + * 获取选择时间戳监听 + * + * @return 选择时间戳监听 + */ + public LongProperty valueProperty() { + return value; + } + + /** + * 获取日期选择器 + * + * @return 日期选择器 + */ + public DatePicker getDatePicker() { + return datePicker; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/EnumListCell.java b/src/main/java/com/imyeyu/fx/ui/components/EnumListCell.java new file mode 100644 index 0000000..4613500 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/EnumListCell.java @@ -0,0 +1,32 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.java.ref.Ref; +import javafx.geometry.Pos; +import javafx.scene.control.ListCell; + +/** + * 通用枚举列表项,默认反射 name 字段 + * + * @param 枚举类型,必须含有 name 字段 + * + * @author 夜雨 + * @since 2023-06-08 17:01 + */ +public class EnumListCell> extends ListCell { + + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(""); + setGraphic(null); + } else { + setAlignment(Pos.CENTER); + try { + setText(Ref.getFieldValue(item, "name", String.class)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/FileTreeView.java b/src/main/java/com/imyeyu/fx/ui/components/FileTreeView.java new file mode 100644 index 0000000..46e9056 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/FileTreeView.java @@ -0,0 +1,473 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ObservableUtils; +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.task.RunAsync; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.ui.components.alert.AlertButton; +import com.imyeyu.fx.ui.components.alert.AlertConfirm; +import com.imyeyu.fx.ui.components.alert.AlertTextField; +import com.imyeyu.fx.ui.components.alert.AlertTips; +import com.imyeyu.io.IO; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.CallbackArgReturn; +import com.imyeyu.utils.OS; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.text.Text; + +import javax.naming.NoPermissionException; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 文件目录树组件 + * + * @author 夜雨 + * @since 2022-05-26 14:32 + */ +public class FileTreeView extends XTreeView implements TimiFXUI.Colorful { + + /** 正在查找节点监听 */ + protected final BooleanBinding findingItem; + + /** 显示隐藏文件监听 */ + protected final BooleanProperty showHide; + + /** 选择队列,左进左出,右边为深度路径,不为空时表正在查找节点 */ + protected final ObservableList selectDeque = FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); + + /** 过滤列表,返回 true 时创建该节点 */ + protected final List> itemFilters; + + /** 默认构造器 */ + public FileTreeView() { + showHide = new SimpleBooleanProperty(false); + itemFilters = new ArrayList<>(); + findingItem = Bindings.isNotEmpty(selectDeque); + + disableProperty().bind(findingItem); + setCellFactory(cell -> new TreeCell<>() { + + final Label loading = new Label(TimiFXUI.MULTILINGUAL.text("loading")); + final Text iconFile = TimiFXIcon.fromName("FILE"); + final Text iconDirectory = TimiFXIcon.fromName("FOLDER"); + + { + iconFile.fillProperty().bind(Bindings.when(FileTreeView.this.focusedProperty().and(selectedProperty())).then(GRAY_WHITE).otherwise(GRAY)); + iconDirectory.fillProperty().bind(Bindings.when(FileTreeView.this.focusedProperty().and(selectedProperty())).then(GRAY_WHITE).otherwise(GRAY)); + } + + @Override + protected void updateItem(File file, boolean empty) { + super.updateItem(file, empty); + if (empty) { + setText(""); + setGraphic(null); + } else { + if (file == null) { + setGraphic(loading); + } else { + if (TimiJava.isEmpty(file.getName())) { + setText(file.toString()); + } else { + setText(file.getName()); + } + setGraphic(file.isFile() ? iconFile : iconDirectory); + } + } + } + }); + + // 右键菜单 + MenuItem menuMkdir = new MenuItem(TimiFXUI.MULTILINGUAL.text("file.mkdir"), TimiFXIcon.fromName("FOLDER_ADD")); + MenuItem menuRename = new MenuItem(TimiFXUI.MULTILINGUAL.text("rename")); + MenuItem menuRefresh = new MenuItem(TimiFXUI.MULTILINGUAL.text("refresh"), TimiFXIcon.fromName("REFRESH")); + MenuItem menuDestroy = new MenuItem(TimiFXUI.MULTILINGUAL.text("delete"), TimiFXIcon.fromName("FAIL", RED)); + + setContextMenu(new ContextMenu(menuMkdir, menuRename, menuRefresh, TimiFXUI.sep(), menuDestroy)); + + // ---------- 事件 ---------- + + // 新建文件夹 + menuMkdir.disableProperty().bind(menuRefresh.disableProperty()); + menuMkdir.setOnAction(e -> mkdir(getSelectionModel().getSelectedItem())); + + // 重命名 + menuRename.disableProperty().bind(ObservableUtils.onlyOnceInList(getSelectionModel().getSelectedItems()).not()); + menuRename.setOnAction(e -> rename(getSelectionModel().getSelectedItem())); + + // 刷新 + menuRefresh.disableProperty().bind(Bindings.createBooleanBinding(() -> { + List> items = getSelectionModel().getSelectedItems(); + // 没有选择、多选、选的不是文件时禁用 + return items == null || items.size() != 1 || items.get(0).getValue().isFile(); + }, getSelectionModel().selectedItemProperty())); + menuRefresh.setOnAction(e -> refreshItem(getSelectionModel().getSelectedItem())); + + // 删除 + List roots = List.of(File.listRoots()); + menuDestroy.disableProperty().bind(Bindings.createBooleanBinding(() -> { + ObservableList> items = getSelectionModel().getSelectedItems(); + if (items.isEmpty()) { + return true; + } + for (int i = 0; i < items.size(); i++) { + if (roots.contains(items.get(i).getValue())) { + return true; + } + } + return false; + }, getSelectionModel().getSelectedItems())); + menuDestroy.setOnAction(e -> destroy(getSelectionModel().getSelectedItems())); + + // 快捷键 + addEventFilter(KeyEvent.KEY_RELEASED, e -> { + boolean control = e.isControlDown(); + boolean shift = e.isShiftDown(); + boolean alt = e.isAltDown(); + KeyCode code = e.getCode(); + + if (!control && !shift && !alt) { + switch (code) { + case F2 -> menuRename.fire(); + case F5 -> menuRefresh.fire(); + case DELETE -> menuDestroy.fire(); + } + } + if (control && shift && !alt && code == KeyCode.N) { + menuMkdir.fire(); + } + }); + + // ---------- 就绪 ---------- + + // 过滤隐藏文件 + CallbackArgReturn filterHidden = file -> !file.isHidden(); + itemFilters.add(filterHidden); + showHide.addListener((obs, o, n) -> { + if (isShowHide()) { + itemFilters.remove(filterHidden); + } else { + itemFilters.add(filterHidden); + } + }); + + // 默认磁盘根目录 + for (int i = 0; i < roots.size(); i++) { + getRoots().add(new FileItem(roots.get(i))); + } + } + + /** + * 创建文件夹 + * + * @param base 基于文件夹 + */ + public void mkdir(TreeItem base) { + if (base == null) { + return; + } + AlertTextField alert = new AlertTextField(TimiFXUI.MULTILINGUAL.text("file.mkdir")); + alert.setTips(TimiFXUI.MULTILINGUAL.text("name")); + alert.setOnActionEvent(action -> { + if (action == AlertButton.Action.CONFIRM) { + try { + IO.dir(IO.fitPath(base.getValue().getAbsolutePath()) + alert.getText()); + refreshItem(getSelectionModel().getSelectedItem()); + } catch (NoPermissionException ex) { + ex.printStackTrace(); + } + } + return true; + }); + alert.autoSize().showRelativeCenter(getScene().getWindow()); + } + + /** + * 重命名 + * + * @param file 文件或文件夹 + */ + public void rename(TreeItem file) { + if (file == null) { + return; + } + AlertTextField alert = new AlertTextField(TimiFXUI.MULTILINGUAL.text("file.rename")); + alert.setTips(TimiFXUI.MULTILINGUAL.text("name")); + alert.setOnActionEvent(action -> { + if (action == AlertButton.Action.CONFIRM) { + IO.rename(getSelectionModel().getSelectedItem().getValue(), alert.getText()); + refreshItem(getSelectionModel().getSelectedItem().getParent()); + } + return true; + }); + alert.autoSize().showRelativeCenter(getScene().getWindow()); + } + + /** + * 销毁文件 + * + * @param files 文件节点列表 + */ + public void destroy(List> files) { + if (TimiJava.isEmpty(files)) { + return; + } + new AlertConfirm(TimiFXUI.MULTILINGUAL.text("file.destroy")) { + + @Override + protected void onConfirm() { + new RunAsync>() { + + @Override + protected TreeItem call() { + List items = files.stream().map(TreeItem::getValue).toList(); + for (int i = 0; i < items.size(); i++) { + IO.destroy(items.get(i)); + } + // 查找最高级节点刷新 + int l = Integer.MAX_VALUE; + TreeItem item = null; + for (int i = 0, j; i < items.size(); i++) { + j = getTreeItemLevel(files.get(i)); + if (j < l) { + l = j; + item = files.get(i); + if (item.getParent() != null) { + item = item.getParent(); + } + } + } + return item; + } + + @Override + protected void onFinish(TreeItem item) { + if (item != null) { + refreshItem(item); + } + } + + @Override + protected void onException(Throwable e) { + AlertTips.error(getScene().getWindow(), TimiFXUI.MULTILINGUAL.text("file.tips.destroy_fail")); + } + }.start(); + } + }.autoSize().showRelativeCenter(getScene().getWindow()); + } + + /** + * 刷新子节点 + * + * @param treeItem 父级节点 + */ + public void refreshItem(TreeItem treeItem) { + if (treeItem instanceof FileItem fileItem) { + fileItem.getChildren().clear(); + fileItem.getChildren().add(new FileItem()); + fileItem.asyncLoadChildren(); + } + } + + /** + * 刷新子节点 + * + * @param fileItem 父级节点 + */ + public void refreshItem(FileItem fileItem) { + fileItem.getChildren().clear(); + fileItem.getChildren().add(new FileItem()); + fileItem.asyncLoadChildren(); + } + + /** + * 选择目标路径 + * + * @param path 路径 + */ + public void selectItem(String path) { + if (TimiJava.isEmpty(path)) { + path = "./"; + } + selectItem(new File(path)); + } + + /** + * 选择目标文件 + * + * @param file 目标文件 + */ + public void selectItem(File file) { + File parent = file.getAbsoluteFile(); + if (!parent.exists()) { + parent = new File("./").getAbsoluteFile(); + } + do { + selectDeque.add(0, parent); + } while ((parent = parent.getParentFile()) != null); + + if (TimiJava.isNotEmpty(selectDeque)) { + ObservableList> roots = getRoots(); + for (int i = 0; i < roots.size(); i++) { + roots.get(i).setExpanded(false); + if (roots.get(i).getValue().equals(selectDeque.get(0))) { + selectDeque.remove(0); + roots.get(i).setExpanded(true); + if (TimiJava.isEmpty(selectDeque)) { + break; + } + } + } + } + } + + /** + * 添加构建节点过滤器,返回 false 时不创建该节点 + * + * @param itemFilter 节点过滤器 + */ + public void addItemFilter(CallbackArgReturn itemFilter) { + itemFilters.add(itemFilter); + } + + /** + * 移除构建节点过滤器 + * + * @param itemFilter 节点过滤器 + */ + public void removeItemFilter(CallbackArgReturn itemFilter) { + itemFilters.remove(itemFilter); + } + + /** + * 设置是否显示隐藏文件 + * + * @param showHide true 为显示隐藏文件 + */ + public void setShowHide(boolean showHide) { + this.showHide.set(showHide); + } + + /** + * 当前是否显示隐藏文件 + * + * @return true 为显示隐藏文件 + */ + public boolean isShowHide() { + return showHide.get(); + } + + /** + * 获取显示隐藏文件监听 + * + * @return 显示隐藏文件监听 + */ + public BooleanProperty showHideProperty() { + return showHide; + } + + /** + * 获取正在查找节点监听,此时属于被动展开,用于阻止主动展开的加载节点 + * + * @return 正在查找节点监听 + */ + public BooleanBinding findingItemProperty() { + return findingItem; + } + + /** + * 文件节点 + * + * @author 夜雨 + * @since 2023-03-16 00:23 + */ + public final class FileItem extends TreeItem { + + /** 默认构造,此时为占位节点 */ + FileItem() { + this(null); + } + + /** + * 标准构造 + * + * @param file 文件 + */ + FileItem(File file) { + super(file); + + if (file != null && file.isDirectory()) { + getChildren().add(new FileItem(null)); // 占位 + } + + // 展开 + expandedProperty().addListener((obs, o, isExpanded) -> { + if (isExpanded) { + getSelectionModel().clearSelection(); + getSelectionModel().select(this); + asyncLoadChildren(); + } else { + getChildren().add(new FileItem()); + } + }); + } + + /** 异步加载子节点 */ + void asyncLoadChildren() { + RunAsync.callbackReturn(() -> { + List fileItems = new ArrayList<>(); + File[] files = getValue().listFiles(); + if (files != null) { + // 排序 + List fileList = Arrays.stream(files).sorted(OS.FileSystem.COMPARATOR_FILE_NAME).toList(); + + // 过滤 + list: + for (int i = 0; i < fileList.size(); i++) { + for (int j = 0; j < itemFilters.size(); j++) { + if (!itemFilters.get(j).handler(fileList.get(i))) { + continue list; + } + } + fileItems.add(new FileItem(fileList.get(i))); + } + } + return fileItems; + }, items -> { + getChildren().setAll(items); + + if (TimiJava.isNotEmpty(selectDeque)) { + File file = selectDeque.remove(0); + for (int i = 0; i < items.size(); i++) { + if (items.get(i).getValue().equals(file)) { + if (items.get(i).getValue().isDirectory()) { + items.get(i).setExpanded(true); + } + break; + } + } + if (TimiJava.isEmpty(selectDeque)) { + // 执行选中 + Platform.runLater(() -> scrollTo(getSelectionModel().getSelectedIndex() - 5)); + } + } + }); + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/IconButton.java b/src/main/java/com/imyeyu/fx/ui/components/IconButton.java new file mode 100644 index 0000000..3453d80 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/IconButton.java @@ -0,0 +1,208 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.TimiJava; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Skin; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.paint.Paint; + +/** + * 图标按钮,可选图片、SVG 路径或 {@link com.imyeyu.fx.icon.TimiFXIcon} 的字体图标 + * + * @author 夜雨 + * @since 2021-12-23 17:58 + */ +public class IconButton extends Button implements TimiFXUI, TimiFXUI.Colorful { + + private static final String STYLE_CLASS = "icon-button"; + + /** 是否自适应尺寸 */ + private final BooleanProperty autoSize; + + private Insets iconPadding, iconTextPadding; // 缓存单独图标内边距和图标文本混合的内边距 + + /** 默认构造器 */ + public IconButton() { + this("", (Node) null); + setAlignment(Pos.CENTER); + } + + // ---------- 图片图标 ---------- + + /** + * 构造图片图标按钮 + * + * @param img 图片 + */ + public IconButton(Image img) { + this("", new ImageView(img)); + } + + /** + * 构造图片图标按钮 + * + * @param text 按钮文本 + * @param img 图片 + */ + public IconButton(String text, Image img) { + this(text, new ImageView(img)); + } + + // ---------- SVG 图标 ---------- + + /** + * 构造 SVG 图标按钮 + * + * @param svgPath SVG 路径 + */ + public IconButton(String svgPath) { + this(svgPath, ICON); + } + + /** + * 构造 SVG 图标按钮 + * + * @param svgPath SVG 路径 + * @param fill 填充颜色 + */ + public IconButton(String svgPath, Paint fill) { + this("", new SVGIcon(svgPath, fill)); + } + + /** + * 构造 SVG 图标按钮 + * + * @param text 按钮文本 + * @param svgPath SVG 路径 + * @param fill 填充颜色 + */ + public IconButton(String text, String svgPath, Paint fill) { + this(text, new SVGIcon(svgPath, fill)); + } + + // ---------- 默认构造 ---------- + + /** + * 构造自定义节点按钮 + * + * @param node 节点 + */ + public IconButton(Node node) { + this("", node); + } + + /** + * 构造自定义节点按钮 + * + * @param text 文本 + * @param node 节点 + */ + public IconButton(String text, Node node) { + super(text); + autoSize = new SimpleBooleanProperty(false); + + getStyleClass().setAll(CSS.MINECRAFT, STYLE_CLASS); + setAlignment(Pos.CENTER); + setMaxHeight(Double.MAX_VALUE); + setPickOnBounds(true); + + if (node != null) { + setGraphic(node); + } + TimiFX.hoverOpacity(this); + + // 自适应尺寸、单独图标、图标文本混合时设置不同的内边距 + paddingProperty().bind(Bindings.createObjectBinding(() -> { + if (autoSize.get()) { + return Insets.EMPTY; + } else { + if (TimiJava.isEmpty(getText())) { + return iconPadding == null ? Insets.EMPTY : iconPadding; + } else { + return iconTextPadding == null ? Insets.EMPTY : iconTextPadding; + } + } + }, textProperty(), autoSize, skinProperty())); + } + + /** + * 添加按钮背景 + * + * @return 本实例 + */ + public IconButton withBackground() { + return withBackground(null); + } + + /** + * 添加按钮背景 + * + * @param borderClass 边框类 + * @return 本实例 + */ + public IconButton withBackground(String borderClass) { + getStyleClass().add(CSS.BG_BUTTON); + if (TimiJava.isNotEmpty(borderClass)) { + getStyleClass().add(borderClass); + } + opacityProperty().unbind(); + return this; + } + + /** + * 自适应尺寸,不使用内边距填充,图标尺寸决定组件尺寸 + * + * @return 本实例 + */ + public IconButton autoSize() { + autoSize.set(true); + return this; + } + + /** + * 获取当前是否自适应尺寸,ture 时图标尺寸决定组件尺寸 + * + * @return true 为自适应尺寸 + */ + public boolean isAutoSize() { + return autoSize.get(); + } + + /** + * 设置是否自适应尺寸,ture 时图标尺寸决定组件尺寸 + * + * @param autoSize true 为自适应尺寸 + */ + public void setAutoSize(boolean autoSize) { + this.autoSize.set(autoSize); + } + + /** + * 获取自适应尺寸监听 + * + * @return 自适应尺寸监听 + */ + public BooleanProperty autoSizeProperty() { + return autoSize; + } + + @Override + protected Skin createDefaultSkin() { + Skin defaultSkin = super.createDefaultSkin(); + double h = getFont().getSize() * .382; + double v = h * .8; + double tv = h * .6; + iconPadding = new Insets(v); + iconTextPadding = new Insets(tv, h, tv, h); + return defaultSkin; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/IconPicker.java b/src/main/java/com/imyeyu/fx/ui/components/IconPicker.java new file mode 100644 index 0000000..4dbced0 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/IconPicker.java @@ -0,0 +1,207 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.utils.BgFill; +import com.imyeyu.fx.utils.SmoothScroll; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.ref.Ref; +import com.imyeyu.utils.Collect; +import javafx.beans.binding.Bindings; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Skin; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.skin.TextFieldSkin; +import javafx.scene.image.Image; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.TilePane; +import javafx.scene.text.Text; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * {@link TimiFXIcon} 的图标选择器(标签搜索需要联网) + * + * @author 夜雨 + * @since 2022-08-01 16:08 + */ +public class IconPicker extends TextField implements TimiFXUI { + + private final BorderPane root; + + /** 默认构造 */ + public IconPicker() { + IconPickerPopup popup = new IconPickerPopup(); + + // 注入面板 + root = new BorderPane(); + + setEditable(false); + + // ---------- 事件 ---------- + + // 更新选择 + textProperty().addListener((obs, o, value) -> { + if (TimiJava.isEmpty(value)) { + root.setLeft(null); + } else { + Text icon = TimiFXIcon.fromName(value); + BorderPane.setMargin(icon, new Insets(0, 2, 0, 0)); + BorderPane.setAlignment(icon, Pos.CENTER); + root.setLeft(icon); + } + }); + + // 点击显示 + setOnMouseClicked(e -> { + Bounds b = localToScreen(getLayoutBounds()); + popup.setX(b.getMinX() - 6); // 6 像素投影 + popup.setY(b.getMaxY() - 6 - 1); + if (popup.getOwner() == null) { + popup.initOwner(getScene().getWindow()); + } + popup.show(); + }); + + // 选择 + popup.group.selectedToggleProperty().addListener((obs, o, newToggle) -> { + if (newToggle == null) { + clear(); + } else if (newToggle instanceof ToggleIcon icon) { + setText(icon.name.toUpperCase()); + } + }); + } + + @Override + protected Skin createDefaultSkin() { + Skin skin = super.createDefaultSkin(); + if (skin instanceof TextFieldSkin textFieldSkin) { + try { + Pane textGroup = Ref.getFieldValue(textFieldSkin, "textGroup", Pane.class); + + root.setCenter(textGroup); + + textFieldSkin.getChildren().setAll(root); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return skin; + } + + /** + * 获取选择的图标图像 + * + * @return 图标图像 + */ + public Image getValue() { + return TimiFXIcon.imageFromName(getText()); + } + + /** + * 图标选择弹窗 + * + * @author 夜雨 + * @since 2022-08-01 17:12 + */ + private static class IconPickerPopup extends Stage { + + private static final String SEARCH_API = "https://api.timiserver.imyeyu.net/icon/search/label"; + + final ToggleGroup group; + final List icons; + final Map nameMapping = TimiFXIcon.getNameMapping(); + + public IconPickerPopup() { + // 图标 + TilePane tile = new TilePane(); + tile.setPadding(new Insets(6)); + + icons = new ArrayList<>(); + group = new ToggleGroup(); + Map items = Collect.sortMapByStringKeyASC(nameMapping); + for (Map.Entry item : items.entrySet()) { + icons.add(new ToggleIcon(group, item.getKey())); + } + tile.getChildren().addAll(icons); + + focusedProperty().addListener((obs, o, isFocused) -> { + if (!isFocused) { + hide(); + } + }); + + Scene scene = new Scene(new StackPane() {{ + setPadding(Shadow.PADDING); + setBackground(BG.TRANSPARENT); + getChildren().add(new BorderPane() {{ + setEffect(Shadow.POPUP); + setBorder(Stroke.DEFAULT); + setMaxHeight(280); + setBackground(BG.DEFAULT); + setCenter(new ScrollPane() {{ + setContent(tile); + setPadding(new Insets(6, 8, 6, 8)); + setFitToWidth(true); + + SmoothScroll.scrollPane(this); + }}); + }}); + }}); + scene.setFill(null); + scene.getStylesheets().addAll(CSS_STYLE, CSS_FONT); + setScene(scene); + setWidth(360); + setHeight(240); + initStyle(StageStyle.TRANSPARENT); + } + } + + /** + * 图标按钮 + * + * @author 夜雨 + * @since 2022-08-01 17:38 + */ + private static class ToggleIcon extends ToggleButton { + + private static final Background SELECTED = new BgFill("#99D1FF").build(); + + /** 图标名称 */ + final String name; + + public ToggleIcon(ToggleGroup ownerGroup, String name) { + super(""); + this.name = name; + + setGraphic(TimiFXIcon.fromName(name)); + getStyleClass().clear(); + managedProperty().bind(visibleProperty()); + borderProperty().bind(Bindings.when(hoverProperty()).then(Stroke.FOCUSED).otherwise(Stroke.TP)); + backgroundProperty().bind(Bindings.when(selectedProperty()).then(SELECTED).otherwise(BG.TRANSPARENT)); + + ownerGroup.getToggles().add(this); + } + + @Override + protected Skin createDefaultSkin() { + Skin defaultSkin = super.createDefaultSkin(); + setPadding(new Insets(getFont().getSize() * .25)); + return defaultSkin; + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/LabelProgressBar.java b/src/main/java/com/imyeyu/fx/ui/components/LabelProgressBar.java new file mode 100644 index 0000000..d60bd6e --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/LabelProgressBar.java @@ -0,0 +1,138 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.java.bean.CallbackArgReturn; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.StringProperty; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.StackPane; + +/** + * 标签进度。如果继承进度组件使用反射注入标签组件时,在设置标签文本会导致标签消失 + * + * @author 夜雨 + * @since 2022-08-09 10:48 + */ +public class LabelProgressBar extends StackPane { + + private static final String STYLE_CLASS = "label-progress-bar"; + + /** 标签 */ + protected final Label label; + + /** 进度 */ + protected final ProgressBar bar; + + /** 标签转换 */ + protected CallbackArgReturn converter; + + /** 默认构造 */ + public LabelProgressBar() { + label = new Label(); + bar = new ProgressBar(); + bar.prefWidthProperty().bind(widthProperty()); + bar.getStyleClass().add(STYLE_CLASS); + + getChildren().addAll(bar, label); + + bar.progressProperty().addListener((obs, o, p) -> { + if (converter != null) { + if (p == null) { + label.setText(converter.handler(-1D)); + } else { + label.setText(converter.handler(p.doubleValue())); + } + } + }); + } + + /** + * 获取当前标签文本 + * + * @return 标签文本 + */ + public String getText() { + return label.getText(); + } + + /** + * 设置标签文本 + * + * @param text 标签文本 + */ + public void setText(String text) { + label.setText(text); + } + + /** + * 获取标签文本监听 + * + * @return 标签文本监听 + */ + public StringProperty textProperty() { + return label.textProperty(); + } + + /** + * 获取标签组件 + * + * @return 标签组件 + */ + public Label getLabel() { + return label; + } + + /** + * 设置进度值 + * + * @param progress 进度值,取值范围 [0, 1] + */ + public void setProgress(double progress) { + bar.setProgress(progress); + } + + /** + * 获取进度值 + * + * @return 进度值 + */ + public double getProgress() { + return bar.getProgress(); + } + + /** + * 获取进度监听 + * + * @return 进度监听 + */ + public DoubleProperty progressProperty() { + return bar.progressProperty(); + } + + /** + * 获取进度组件 + * + * @return 进度组件 + */ + public ProgressBar getBar() { + return bar; + } + + /** + * 获取标签转换回调 + * + * @return 转换回调 + */ + public CallbackArgReturn getConverter() { + return converter; + } + + /** + * 设置标签转换回调,进度变换时触发回调 + * + * @param converter 标签转换回调 + */ + public void setConverter(CallbackArgReturn converter) { + this.converter = converter; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/Navigation.java b/src/main/java/com/imyeyu/fx/ui/components/Navigation.java new file mode 100644 index 0000000..0d22645 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/Navigation.java @@ -0,0 +1,243 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.utils.SmoothScroll; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TitledPane; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.VBox; + +import java.util.List; + +/** + * 纵向导航组件,可实现二级导航,折叠导航 + * + * @author 夜雨 + * @since 2022-02-17 00:11 + */ +public class Navigation extends ScrollPane implements TimiFXUI { + + /** 导航列表项 */ + protected final ObservableList items; + + /** 已选中监听 */ + protected final ObjectProperty selectedItem; + + /** 默认构造器 */ + public Navigation() { + items = FXCollections.observableArrayList(); + selectedItem = new SimpleObjectProperty<>(); + + VBox root = new VBox(); + root.setBorder(Stroke.BOTTOM); + + getStyleClass().addAll("navigation", "sp-border"); + setMaxWidth(Double.MAX_VALUE); + setVbarPolicy(ScrollBarPolicy.NEVER); + setFitToWidth(true); + setContent(root); + + SmoothScroll.scrollPaneV(this); + + // ---------- 事件 ---------- + + ToggleGroup group = new ToggleGroup(); + + // 主动选择(代码触发) + selectedItem.addListener((obs, o, newSelectedItem) -> group.selectToggle(newSelectedItem)); + + // 被动选择(操作触发) + group.selectedToggleProperty().addListener((obs, o, toggle) -> { + if (toggle instanceof ToggleButton btn) { + selectedItem.set(btn); + } + }); + ObservableList childrens = root.getChildren(); + + // 响应 TimiFXUI + items.addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasAdded()) { + // 添加 + List list = c.getAddedSubList(); + for (int i = 0; i < list.size(); i++) { + list.get(i).setMaxWidth(Double.MAX_VALUE); + list.get(i).getStyleClass().setAll(CSS.MINECRAFT, "navigation-button"); + list.get(i).addEventFilter(MouseEvent.MOUSE_PRESSED, TimiFX.EVENT_CONSUME_TG_BTN); + + if (list.get(i).getProperties().get("OWNER") instanceof TitledPane pane) { + // 存在所属组 + if (!childrens.contains(pane)) { + // 未添加组 + childrens.add(pane); + } + if (pane.getContent() instanceof VBox box) { + box.getChildren().add(list.get(i)); + } + } else { + // 单独项 + if (!childrens.isEmpty()) { + if (childrens.get(childrens.size() - 1) instanceof TitledPane) { + // 上一项是组导航,添加上边框 + list.get(i).getStyleClass().add("after-group"); + } + } + childrens.add(list.get(i)); + } + // 归组 + group.getToggles().add(list.get(i)); + } + return; + } + if (c.wasRemoved()) { + // 移除 + List list = c.getRemoved(); + for (int i = 0; i < list.size(); i++) { + if (list.get(i).getProperties().get("OWNER") instanceof TitledPane pane) { + // 存在所属组 + if (pane.getContent() instanceof VBox box) { + box.getChildren().remove(list.get(i)); + if (box.getChildren().isEmpty()) { + // 该组已没有列表项 + childrens.remove(box); + } + } + } else { + // 单独项 + childrens.remove(list.get(i)); + } + // 从组移除 + group.getToggles().remove(list.get(i)); + } + } + } + }); + } + + /** + * 添加导航按钮 + * + * @param buttons 导航按钮 + */ + public void add(ToggleButton... buttons) { + getItems().addAll(buttons); + } + + /** + * 添加默认没有展开的导航组 + * + * @param title 标题 + * @param buttons 导航项 + * @return 构造的折叠面板 + */ + public TitledPane addGroup(String title, ToggleButton... buttons) { + return addGroup(title, false, buttons); + } + + /** + * 添加默认展开的导航组 + * + * @param title 标题 + * @param buttons 导航项 + * @return 构造的折叠面板 + */ + public TitledPane addExpandedGroup(String title, ToggleButton... buttons) { + return addGroup(title, true, buttons); + } + + /** + * 添加导航组 + * + * @param title 标题 + * @param isExpanded true 为默认展开 + * @param buttons 导航项 + * @return 构造的折叠面板 + */ + public TitledPane addGroup(String title, boolean isExpanded, ToggleButton... buttons) { + TitledPane pane = new TitledPane(); + pane.setText(title); + return addGroup(pane, isExpanded, buttons); + } + + /** + * 添加导航组 + * + * @param pane 所属组 + * @param isExpanded true 为默认展开 + * @param buttons 导航项 + * @return 原折叠面板 + */ + public TitledPane addGroup(TitledPane pane, boolean isExpanded, ToggleButton... buttons) { + VBox content = new VBox(); + content.setPadding(Insets.EMPTY); + + pane.setContent(content); + pane.setExpanded(isExpanded); + pane.getStyleClass().add("group-pane"); + + for (int i = 0; i < buttons.length; i++) { + buttons[i].getProperties().put("OWNER", pane); + items.add(buttons[i]); + } + return pane; + } + + /** + * 获取该按钮所属组 + * + * @param btn 按钮 + * @return 所属组,null 时为不属于任何组 + */ + public TitledPane getGroup(ToggleButton btn) { + if (btn.getProperties().get("OWNER") instanceof TitledPane pane) { + return pane; + } + return null; + } + + /** + * 设置当前激活导航项 + * + * @param button 导航项 + */ + public void setSelectedItem(ToggleButton button) { + selectedItem.set(button); + } + + /** + * 获取当前激活的导航项 + * + * @return 当前激活的导航项 + */ + public ToggleButton getSelectedItem() { + return selectedItem.get(); + } + + /** + * 获取激活导航项监听 + * + * @return 激活导航项监听 + */ + public ObjectProperty selectedItem() { + return selectedItem; + } + + /** + * 获取导航数据列表 + * + * @return 导航数据列表 + */ + public ObservableList getItems() { + return items; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/NumberField.java b/src/main/java/com/imyeyu/fx/ui/components/NumberField.java new file mode 100644 index 0000000..2700440 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/NumberField.java @@ -0,0 +1,152 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.java.TimiJava; +import com.imyeyu.utils.Calc; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +/** + * 数字输入框 + * + * @author 夜雨 + * @since 2021-12-29 21:59 + */ +public class NumberField extends TextField { + + /** 数值 */ + private final DoubleProperty value; + + private boolean isBackSpace = false; + + /** 默认构造 */ + public NumberField() { + this(""); + } + + /** + * 数字输入组件构造 + * + * @param text 默认数值字符串 + */ + public NumberField(String text) { + super(text); + + value = new SimpleDoubleProperty(); + value.addListener((obs, o, newValue) -> { + if (newValue.doubleValue() % 1 == 0) { + setText(String.valueOf(newValue.intValue())); + } else { + setText(String.valueOf(newValue.doubleValue())); + } + }); + + textProperty().addListener((obs, o, newText) -> { + if (Calc.isNumber(newText)) { + if (getDouble() % 1 == 0) { + value.set(getInt()); + } else { + value.set(getDouble()); + } + } + }); + addEventFilter(KeyEvent.KEY_PRESSED, e -> isBackSpace = e.getCode() == KeyCode.BACK_SPACE); + setTextFormatter(new TextFormatter<>(c -> { + String newText = c.getControlNewText(); + if (!newText.equals("")) { + if (newText.equals("+") || newText.equals("-")) { + return c; + } + if (Calc.isNumber(newText)) { + if (isBackSpace && newText.endsWith(".")) { + c.setRange(c.getRangeStart() - 1, c.getRangeEnd()); + return c; + } + return c; + } else { + return null; + } + } else { + return c; + } + })); + } + + /** + * 设置当前值 + * + * @param number 当前值 + */ + public void setValue(Number number) { + setText(String.valueOf(number)); + } + + /** + * 获取为双精度浮点值 + * + * @return 双精度浮点值 + */ + public double getDouble() { + return getValue().doubleValue(); + } + + /** + * 获取为长整值 + * + * @return 长整值 + */ + public long getLong() { + return getValue().longValue(); + } + + /** + * 获取为短整值 + * + * @return 短整值 + */ + public int getInt() { + return getValue().intValue(); + } + + /** + * 获取为单精度浮点值 + * + * @return 单精度浮点值 + */ + public float getFloat() { + return getValue().floatValue(); + } + + /** + * 获取数字对象 + * + * @return 数字对象 + */ + public Number getValue() { + if (TimiJava.isEmpty(getText())) { + return null; + } + return Double.parseDouble(getText()); + } + + /** + * 获取数值属性 + * + * @return 数值属性 + */ + public DoubleProperty valueProperty() { + return value; + } + + /** + * 设置值 + * + * @param value 值 + */ + public void setValue(double value) { + this.value.set(value); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/ProgressSlider.java b/src/main/java/com/imyeyu/fx/ui/components/ProgressSlider.java new file mode 100644 index 0000000..71a036f --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/ProgressSlider.java @@ -0,0 +1,55 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.ref.Ref; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.Skin; +import javafx.scene.control.Slider; +import javafx.scene.control.skin.SliderSkin; +import javafx.scene.layout.StackPane; + +/** + * 带有进度的滑动选中,通常是媒体播放进度或音量进度(未对纵向滑动组件测试) + * + * @author 夜雨 + * @since 2021-11-09 21:34 + */ +public class ProgressSlider extends Slider implements TimiFXUI { + + private static final String STYLE_CLASS = "progress-slider"; + + /** 默认构造,范围 [0, 1],默认值 0 */ + public ProgressSlider() { + this(0, 1, 0); + } + + /** + * 标准构造 + * + * @param min 最小值 + * @param max 最大值 + * @param value 当前值 + */ + public ProgressSlider(double min, double max, double value) { + super(min, max, value); + getStyleClass().add(STYLE_CLASS); + } + + @Override + protected Skin createDefaultSkin() { + Skin skin = super.createDefaultSkin(); + if (skin instanceof SliderSkin) { + try { + StackPane track = Ref.getFieldValue(skin, "track", StackPane.class); + ProgressBar pb = new ProgressBar(); + pb.progressProperty().bind(valueProperty().subtract(minProperty()).divide(maxProperty().subtract(minProperty()))); + pb.prefWidthProperty().bind(track.widthProperty()); + pb.setMouseTransparent(true); + track.getChildren().add(0, pb); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return skin; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/SVGIcon.java b/src/main/java/com/imyeyu/fx/ui/components/SVGIcon.java new file mode 100644 index 0000000..15a783b --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/SVGIcon.java @@ -0,0 +1,69 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ui.TimiFXUI; +import javafx.scene.paint.Paint; +import javafx.scene.shape.SVGPath; + +/** + * SVG 图标,主要简化 SVGPath 构造函数和可克隆({@link #renew()}) + * + * @author 夜雨 + * @since 2021-02-13 13:35 + */ +public class SVGIcon extends SVGPath implements TimiFXUI, TimiFXUI.Colorful { + + private final Paint color; + private final String path; + + /** + * 构造 SVG 图标 + * + * @param path SVG 路径 + */ + public SVGIcon(String path) { + this(path, ICON); + } + + /** + * 构造 SVG 图标 + * + * @param path SVG 路径 + * @param color 颜色 + */ + public SVGIcon(String path, String color) { + this(path, Paint.valueOf(color)); + } + + /** + * 构造 SVG 图标 + * + * @param color 颜色 + * @param path SVG 路径 + */ + public SVGIcon(Paint color, String path) { + this(path, color); + } + + /** + * 构造 SVG 图标 + * + * @param path SVG 路径 + * @param color 颜色 + */ + public SVGIcon(String path, Paint color) { + this.path = path; + this.color = color; + + setFill(color); + setContent(path); + } + + /** + * 自定克隆 + * + * @return 克隆对象 + */ + public SVGIcon renew() { + return new SVGIcon(path, color); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/SelectableLabel.java b/src/main/java/com/imyeyu/fx/ui/components/SelectableLabel.java new file mode 100644 index 0000000..f8cd988 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/SelectableLabel.java @@ -0,0 +1,172 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.ref.Ref; +import com.sun.javafx.scene.control.skin.Utils; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.ListChangeListener; +import javafx.scene.Cursor; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Skin; +import javafx.scene.control.TextArea; +import javafx.scene.control.skin.TextAreaSkin; +import javafx.scene.layout.Region; +import javafx.scene.paint.Paint; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; + +import java.util.List; + +/** + * 可选中的标签组件,实际上是无样式文本域,此组件适应布局最大宽度 + * + * @author 夜雨 + * @since 2022-01-29 01:24 + */ +public class SelectableLabel extends TextArea implements TimiFXUI, TimiFXUI.Colorful { + + private static final String STYLE_CLASS = "selectable-label"; + + private final ObjectProperty textFillProperty; + private final ObjectProperty textAlignmentProperty; + + /** 默认构造 */ + public SelectableLabel() { + this(""); + } + + /** + * 构造器 + * + * @param text 文本内容 + */ + public SelectableLabel(String text) { + super(text); + + textFillProperty = new SimpleObjectProperty<>(BLACK); + textAlignmentProperty = new SimpleObjectProperty<>(TextAlignment.LEFT); + + setCursor(Cursor.TEXT); + setEditable(false); + setWrapText(true); + setMaxWidth(Double.MAX_VALUE); + setMinSize(Double.MIN_VALUE, Double.MIN_VALUE); + setPrefHeight(0); + setPrefSize(0, 0); + getStyleClass().setAll(STYLE_CLASS, CSS.MINECRAFT); + + focusedProperty().addListener((obs, o, isFocused) -> { + if (!isFocused) { + deselect(); + } + }); + } + + @Override + protected Skin createDefaultSkin() { + Skin defaultSkin = super.createDefaultSkin(); + if (defaultSkin instanceof TextAreaSkin skin) { + try { + ScrollPane sp = Ref.getFieldValue(skin, "scrollPane", ScrollPane.class); + sp.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + if (sp.getContent() instanceof Region region) { + region.heightProperty().addListener((obs, o, newHeight) -> Platform.runLater(() -> { + // 需要 runLater,因为是 Region 适应 Skin 变化 + setPrefHeight(newHeight.doubleValue()); + })); + } + Group paragraphNodes = Ref.getFieldValue(skin, "paragraphNodes", Group.class); + paragraphNodes.getChildren().addListener((ListChangeListener) c -> { + if (c.next()) { + if (c.wasAdded()) { + bindTextStyle(c.getAddedSubList()); + } + } + }); + bindTextStyle(paragraphNodes.getChildren()); + widthProperty().addListener((obs, oldValue, newValue) -> { + if (oldValue.doubleValue() < newValue.doubleValue()) { + if (paragraphNodes.getChildren().get(0) instanceof Text text) { + setPrefHeight(Utils.computeTextHeight(getFont(), getText(), getWidth(), text.getBoundsType())); + } + } + }); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return defaultSkin; + } + + /** + * 绑定文本样式 + * + * @param list 文本节点,必须是 {@link Text} + */ + private void bindTextStyle(List list) { + for (int i = 0; i < list.size(); i++) { + if (list.get(i) instanceof Text text) { + text.fillProperty().bind(textFillProperty); + text.textAlignmentProperty().bind(textAlignmentProperty); + } + } + } + + /** + * 设置字体颜色 + * + * @param textFill 字体颜色 + */ + public void setTextFill(Paint textFill) { + this.textFillProperty.set(textFill); + } + + /** + * 获取字体颜色 + * + * @return 字体颜色 + */ + public Paint getTextFill() { + return textFillProperty.get(); + } + + /** + * 获取字体颜色绑定 + * + * @return 字体颜色绑定 + */ + public ObjectProperty textFillProperty() { + return textFillProperty; + } + + /** + * 设置文本对齐方式 + * + * @param textAlignment 文本对齐方式 + */ + public void setTextAlignment(TextAlignment textAlignment) { + this.textAlignmentProperty.set(textAlignment); + } + + /** + * 获取文本对齐方式 + * + * @return 文本对齐方式 + */ + public TextAlignment getTextAlignment() { + return textAlignmentProperty.get(); + } + + /** + * 获取文本对齐方式绑定 + * + * @return 文本对齐方式绑定 + */ + public ObjectProperty textAlignmentProperty() { + return textAlignmentProperty; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/TextAreaEditor.java b/src/main/java/com/imyeyu/fx/ui/components/TextAreaEditor.java new file mode 100644 index 0000000..c240971 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/TextAreaEditor.java @@ -0,0 +1,773 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.ui.components.popup.PopupTipsService; +import com.imyeyu.fx.utils.Anchor; +import com.imyeyu.fx.utils.Column; +import com.imyeyu.fx.utils.SmoothScroll; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.Callback; +import com.imyeyu.java.ref.Ref; +import com.sun.javafx.scene.control.skin.Utils; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableMap; +import javafx.event.Event; +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Group; +import javafx.scene.control.Button; +import javafx.scene.control.IndexRange; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollBar; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Skin; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputControl; +import javafx.scene.control.skin.TextAreaSkin; +import javafx.scene.control.skin.TextFieldSkin; +import javafx.scene.control.skin.TextInputControlSkin; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Path; +import javafx.scene.text.Text; + +import java.util.HashMap; +import java.util.Map; + +/** + * 复杂文本域编辑器,显示按钮操作文本域,文本域显示行号,可自定义操作功能 + * + * @author 夜雨 + * @since 2022-07-11 15:47 + */ +public class TextAreaEditor extends TextArea implements TimiFXUI { + + private static final String STYLE_CLASS = "text-area-editor"; + + /** 主要控制区,此面板在 {@link #header} 的中部 */ + protected final HBox ctrl; + + /** 顶部控制区 */ + protected final BorderPane header; + + /** 撤销按钮 */ + protected final IconButton undo; + + /** 重做按钮 */ + protected final IconButton redo; + + /** 复制按钮 */ + protected final IconButton copy; + + /** 剪切按钮 */ + protected final IconButton cut; + + /** 粘贴按钮 */ + protected final IconButton paste; + + /** 换行按钮 */ + protected final ToggleIcon wrap; + + /** 显示行号 */ + protected final BooleanProperty showLineNumber; + + /** 显示查找面板 */ + protected BooleanProperty visibleFindPaneProperty; + + /** 发生换行的文本节点 Map<行号, 换行次数> */ + private final Map wraps; + + /** 行号组件 */ + private LineNumber lineNumber; + + /** 根布局 */ + private final BorderPane root; + + /** 默认构造 */ + public TextAreaEditor() { + wraps = new HashMap<>(); + showLineNumber = new SimpleBooleanProperty(true); + + // 撤销 + undo = new IconButton(TimiFXIcon.fromName("ARROW_0_W")).withBackground(CSS.BORDER_R); + undo.disableProperty().bind(undoableProperty().not()); + PopupTipsService.installText(undo, TimiFXUI.MULTILINGUAL.text("undo")); + + // 重做 + redo = new IconButton(TimiFXIcon.fromName("ARROW_0_E")).withBackground(CSS.BORDER_R); + redo.disableProperty().bind(redoableProperty().not()); + PopupTipsService.installText(redo, TimiFXUI.MULTILINGUAL.text("redo")); + + // 复制 + copy = new IconButton(TimiFXIcon.fromName("COPY")).withBackground(CSS.BORDER_R); + PopupTipsService.installText(copy, TimiFXUI.MULTILINGUAL.text("copy")); + + // 剪切 + cut = new IconButton(TimiFXIcon.fromName("CUT")).withBackground(CSS.BORDER_R); + cut.disableProperty().bind(editableProperty().not()); + PopupTipsService.installText(cut, TimiFXUI.MULTILINGUAL.text("cut")); + + // 粘贴 + paste = new IconButton(TimiFXIcon.fromName("PASTE")).withBackground(CSS.BORDER_R); + paste.disableProperty().bind(editableProperty().not()); + PopupTipsService.installText(paste, TimiFXUI.MULTILINGUAL.text("paste")); + + // 换行 + wrap = new ToggleIcon(TimiFXIcon.fromName("WRAP")); + wrap.setBorder(Stroke.RIGHT); + PopupTipsService.installText(wrap, TimiFXUI.MULTILINGUAL.text("wrap")); + + ctrl = new HBox(); + ctrl.setPickOnBounds(false); + ctrl.getChildren().addAll(undo, redo, copy, cut, paste, wrap); + + header = new BorderPane(); + header.setCenter(ctrl); + header.setPickOnBounds(false); + + // 顶部背景(阻止触发文本域选择) + Button headerBackground = new Button(" "); + headerBackground.getStyleClass().addAll(CSS.BORDER_N, CSS.BG_TP); + headerBackground.setBackground(BG.DEFAULT); + + AnchorPane headerPane = new AnchorPane(); + Anchor.def(header); + Anchor.def(headerBackground); + headerPane.getChildren().setAll(headerBackground, header); + headerPane.setBorder(Stroke.BOTTOM); + + // 搜索 + FindPane findPane = new FindPane(this); + visibleFindPaneProperty = findPane.visibleProperty(); + findPane.managedProperty().bind(visibleFindPaneProperty); + findPane.setVisible(false); + + root = new BorderPane(); + root.setTop(new VBox() {{ + getChildren().addAll(headerPane, findPane); + }}); + + getStyleClass().add(STYLE_CLASS); + + // ---------- 事件 ---------- + + undo.setOnAction(e -> undo()); + redo.setOnAction(e -> redo()); + copy.setOnAction(e -> copy()); + cut.setOnAction(e -> cut()); + paste.setOnAction(e -> paste()); + wrapTextProperty().bindBidirectional(wrap.selectedProperty()); + + // 键盘事件 + addEventFilter(KeyEvent.KEY_RELEASED, e -> { + boolean control = e.isControlDown(); + boolean shift = e.isShiftDown(); + boolean alt = e.isAltDown(); + KeyCode code = e.getCode(); + + if (control && shift && !alt) { + switch (code) { + case ENTER -> { + // 向上开新行 + int start = getText().lastIndexOf("\n", getCaretPosition() - 1); + if (start == -1) { + insertText(0, "\n"); + positionCaret(0); + } else { + insertText(start, "\n"); + positionCaret(start + 1); + } + } + case U -> { + // 切换大小写 + selectPreviousWord(); + positionCaret(getSelection().getStart()); + selectEndOfNextWord(); + + IndexRange range = getSelection(); + String text = getSelectedText(); + if (65 <= text.charAt(0) && text.charAt(0) <= 90) { + // 当前大小 + replaceSelection(text.toLowerCase()); + } else { + // 当前小写 + replaceSelection(text.toUpperCase()); + } + selectRange(range.getStart(), range.getEnd()); + } + } + return; + } + if (control && !shift && !alt) { + switch (code) { + case F -> { + // 打开查找面板 + findPane.setVisible(!findPane.isVisible()); + findPane.keyword.setText(getSelectedText()); + } + case D -> { + // 删除聚焦行 + int start = getText().lastIndexOf("\n", getCaretPosition() - 1); + start = Math.max(start, 0); + int end = getText().indexOf("\n", getCaretPosition()); + end = end < 0 ? getText().length() : end; + + deleteText(start, end); + } + } + return; + } + if (!control && shift && !alt && code == KeyCode.ENTER) { + // 向下开新行 + int end = getText().indexOf("\n", getCaretPosition()); + end = end < 0 ? getText().length() : end; + + insertText(end, "\n"); + return; + } + if (!control && !shift && !alt && code == KeyCode.ESCAPE) { + // 隐藏查找面板 + findPane.setVisible(false); + } + }); + } + + @Override + protected Skin createDefaultSkin() { + TextAreaEditSkin skin = new TextAreaEditSkin(this); + try { + // 嵌入面板 + ScrollPane scrollPane = Ref.getClassFieldValue(skin, TextAreaSkin.class, "scrollPane", ScrollPane.class); + scrollPane.getStyleClass().add(CSS.SP_BORDER); + // 锐化光标 + Path caret = Ref.getClassFieldValue(skin, TextInputControlSkin.class, "caretPath", Path.class); + caret.setSmooth(false); + // 插入行号 + double fontSize = skin.getSkinnable().getFont().getSize() + 2; + lineNumber = new LineNumber(scrollPane, fontSize); + lineNumber.visibleProperty().bind(showLineNumber); + lineNumber.managedProperty().bind(showLineNumber); + + // 内容监听,计算行号 + Group paragraphNodes = Ref.getClassFieldValue(skin, TextAreaSkin.class, "paragraphNodes", Group.class); + Callback lineNumberParser = () -> { + wraps.clear(); + if (isWrapText() && paragraphNodes.getChildren().get(0) instanceof Text text) { + // 计算段落被渲染换行数 + double wrappingWidth = scrollPane.getWidth() - 12; + for (int i = 0, l = getParagraphs().size(); i < l; i++) { + int wrap = (int) (Utils.computeTextHeight(getFont(), getParagraphs().get(i).toString(), wrappingWidth, text.getBoundsType()) / fontSize); + if (wrap != 1) { + wraps.put(i, wrap - 1); + } + } + } + lineNumber.render(getParagraphs().size()); + }; + getParagraphs().addListener((ListChangeListener) c -> { + if (c.next()) { + lineNumberParser.handler(); + } + }); + wrapTextProperty().addListener((obs, o, n) -> lineNumberParser.handler()); + widthProperty().addListener((obs, o, n) -> lineNumberParser.handler()); + // 适应底部滚动条 + scrollPane.skinProperty().addListener((obs, o, spSkin) -> { + try { + ScrollBar hsb = Ref.getFieldValue(spSkin, "hsb", ScrollBar.class); + lineNumber.scrollPane.paddingProperty().bind(Bindings.createObjectBinding(() -> { + if (hsb.isVisible()) { + double height = Ref.getFieldValue(spSkin, "hsbHeight", Double.class); + return new Insets(0, 0, height, 0); + } else { + return Insets.EMPTY; + } + }, hsb.visibleProperty())); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }); + + // 平滑滚动 + SmoothScroll.scrollPaneV(scrollPane); + + root.setLeft(lineNumber); + root.setCenter(scrollPane); + skin.getChildren().setAll(root); + + lineNumber.render(getParagraphs().size()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return skin; + } + + /** + * 获取控制区面板,此面板在 {@link #getHeader()} 的中部 + * + * @return 控制区面板 + */ + public HBox getCtrl() { + return ctrl; + } + + /** + * 获取顶部控制区面板 + * + * @return 顶部控制区面板 + */ + public BorderPane getHeader() { + return header; + } + + /** + * 获取撤销按钮 + * + * @return 撤销按钮 + */ + public IconButton getUndo() { + return undo; + } + + /** + * 获取重做按钮 + * + * @return 重做按钮 + */ + public IconButton getRedo() { + return redo; + } + + /** + * 获取复制按钮 + * + * @return 复制按钮 + */ + public IconButton getCopy() { + return copy; + } + + /** + * 获取剪切按钮 + * + * @return 剪切按钮 + */ + public IconButton getCut() { + return cut; + } + + /** + * 获取粘贴按钮 + * + * @return 粘贴按钮 + */ + public IconButton getPaste() { + return paste; + } + + /** + * 获取换行按钮 + * + * @return 换行按钮 + */ + public ToggleIcon getWrap() { + return wrap; + } + + /** + * 获取是否显示行号 + * + * @return true 为显示行号 + */ + public boolean isShowLineNumber() { + return showLineNumber.get(); + } + + /** + * 设置是否显示行号 + * + * @param showLineNumber true 为显示行号 + */ + public void setShowLineNumber(boolean showLineNumber) { + this.showLineNumber.set(showLineNumber); + } + + /** + * 获取是否显示行号监听 + * + * @return 显示行号监听 + */ + public BooleanProperty showLineNumberProperty() { + return showLineNumber; + } + + /** + * 获取是否显示查找面板 + * + * @return true 为显示查找面板 + */ + public boolean isVisibleFindPane() { + return visibleFindPaneProperty.get(); + } + + /** + * 设置是否显示查找面板 + * + * @param visibleFindPane true 为显示查找面板 + */ + public void setVisibleFindPane(boolean visibleFindPane) { + this.visibleFindPaneProperty.set(visibleFindPane); + } + + /** + * 获取是否显示查找面板监听 + * + * @return 显示查找面板监听 + */ + public BooleanProperty visibleFindPaneProperty() { + return visibleFindPaneProperty; + } + + /** + * 字符串查找面板 + * + * @author 夜雨 + * @since 2022-08-26 18:01 + */ + private class FindPane extends GridPane { + + final Callback fetch; + final Label result; + final TextField keyword; + + // Map<下标, 起始选择> + private final ObservableMap selects; + + // 当前查找结果选中下标 + private int nearestI; + + private ToggleIcon toggleCase; + + public FindPane(TextInputControl inputControl) { + selects = FXCollections.observableHashMap(); + + // 查找 + Text icon = TimiFXIcon.fromName("MAGNIFIER"); + keyword = new TextField() { + + @Override + protected Skin createDefaultSkin() { + Skin skin = super.createDefaultSkin(); + if (skin instanceof TextFieldSkin textFieldSkin) { + try { + + // 输入 + Pane textGroup = Ref.getFieldValue(textFieldSkin, "textGroup", Pane.class); + textGroup.setTranslateY(-.5); + + // 匹配大小写 + toggleCase = new ToggleIcon(TimiFXIcon.fromName("FONTSIZE")); + toggleCase.setBorder(Stroke.LEFT); + toggleCase.setCursor(Cursor.DEFAULT); + toggleCase.setFocusTraversable(false); + toggleCase.selectedProperty().addListener((obs, o, n) -> fetch.handler()); + + BorderPane root = new BorderPane(); + root.setCenter(textGroup); + root.setRight(new HBox(toggleCase)); + + textFieldSkin.getChildren().setAll(root); + + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return skin; + } + }; + keyword.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + keyword.getStyleClass().addAll("find-field", CSS.BORDER_R, CSS.PADDING_N); + + // 查找结果 + result = TimiFXUI.label(" "); + result.setPadding(new Insets(0, 4, 0, 4)); + IconButton prev = new IconButton(TimiFXIcon.fromName("ARROW_0_N")).withBackground(); + prev.getStyleClass().add(CSS.BORDER_R); + prev.setFocusTraversable(false); + IconButton next = new IconButton(TimiFXIcon.fromName("ARROW_0_S")).withBackground(); + next.getStyleClass().add(CSS.BORDER_R); + next.setFocusTraversable(false); + + // 关闭 + IconButton close = new IconButton(TimiFXIcon.fromName("FAIL")).withBackground(); + close.getStyleClass().add(CSS.BORDER_L); + close.setFocusTraversable(false); + + // 替换值 + TextField replaceValue = new TextField(); + replaceValue.getStyleClass().addAll("replace-field", CSS.BORDER_TR); + replaceValue.disableProperty().bind(editableProperty().not()); + Button replace = new Button(TimiFXUI.MULTILINGUAL.text("replace")); + replace.getStyleClass().add(CSS.BORDER_R); + replace.setFocusTraversable(false); + replace.disableProperty().bind(editableProperty().not().or(replaceValue.textProperty().isEmpty().or(Bindings.isEmpty(selects)))); + replace.addEventFilter(MouseEvent.MOUSE_DRAGGED, e -> { + if (replaceValue.isDisabled()) { + e.consume(); + } + }); + Button replaceAll = new Button(TimiFXUI.MULTILINGUAL.text("replace_all")); + replaceAll.getStyleClass().add(CSS.BORDER_R); + replaceAll.setFocusTraversable(false); + replaceAll.disableProperty().bind(replace.disabledProperty()); + + getStyleClass().add("find-pane"); + getColumnConstraints().addAll(Column.build(), Column.build().width(260), Column.VALUE_FILL); + setBorder(Stroke.BOTTOM); + setBackground(BG.DEFAULT); + + addRow(0, new StackPane() {{ + setPadding(new Insets(0, 2, 0, 2)); + setBackground(BG.WHITE); + getChildren().add(icon); + }}, keyword, new BorderPane() {{ + setCenter(new HBox() {{ + setAlignment(Pos.CENTER_LEFT); + getChildren().addAll(prev, next, result); + }}); + setRight(close); + }}); + + add(replaceValue, 0, 1, 2, 1); + addRow(1, new HBox() {{ + setBorder(Stroke.TOP); + getChildren().addAll(replace, replaceAll); + }}); + + // ---------- 事件 ---------- + + // 忽略拖拽 + addEventFilter(MouseEvent.MOUSE_DRAGGED, e -> { + if (!keyword.isFocused() && !replaceValue.isFocused()) { + e.consume(); + } + }); + + // 刷新搜索结果 + Callback updateResult = () -> { + if (TimiJava.isEmpty(selects) || nearestI == -1) { + result.setText(" "); + } else { + inputControl.selectRange(selects.get(nearestI), selects.get(nearestI) + keyword.getLength()); + result.setText("%s / %s".formatted(nearestI + 1, selects.size())); + } + }; + + // 搜索 + fetch = () -> { + inputControl.deselect(); + selects.clear(); + if (TimiJava.isEmpty(keyword.getText())) { + result.setText(" "); + } else { + String text, keywordValue; + if (toggleCase.isSelected()) { + text = inputControl.getText(); + keywordValue = keyword.getText(); + } else { + text = inputControl.getText().toLowerCase(); + keywordValue = keyword.getText().toLowerCase(); + } + int keywordLength = keywordValue.length(); + + nearestI = -1; + boolean isFoundNearestI = false; + for (int i = 0, l = text.length(); i < l; i += keywordLength) { + i = text.indexOf(keywordValue, i); + if (i == -1) { + break; + } else { + selects.put(selects.size(), i); + if (!isFoundNearestI && inputControl.getCaretPosition() < i) { + nearestI = selects.size() - 1; + isFoundNearestI = true; + } + } + } + if (!isFoundNearestI && TimiJava.isNotEmpty(selects)) { + nearestI = 0; + } + updateResult.handler(); + } + }; + + // 查找 + keyword.textProperty().addListener((obs, o, newKeyword) -> fetch.handler()); + + // 更新查找 + inputControl.textProperty().addListener((obs, o, n) -> { + if (TimiJava.isNotEmpty(keyword.getText()) && isVisible()) { + fetch.handler(); + } + }); + + // 选中上一个 + prev.setOnAction(e -> { + if (TimiJava.isNotEmpty(selects)) { + nearestI--; + if (nearestI < 0) { + nearestI = selects.size() - 1; + } + updateResult.handler(); + } + }); + + // 选中下一个 + next.setOnAction(e -> { + if (TimiJava.isNotEmpty(selects)) { + nearestI++; + if (selects.size() - 1 < nearestI) { + nearestI = 0; + } + updateResult.handler(); + } + }); + + // 替换 + replace.setOnAction(e -> { + if (inputControl.getSelectedText().equals(keyword.getText())) { + inputControl.replaceSelection(replaceValue.getText()); + } else { + int i = inputControl.getText().indexOf(keyword.getText(), inputControl.getCaretPosition()); + if (i == -1) { + i = inputControl.getText().indexOf(keyword.getText()); + } + if (i != -1) { + inputControl.replaceText(i, i + keyword.getLength(), replaceValue.getText()); + } + inputControl.positionCaret(i + replaceValue.getLength()); + } + fetch.handler(); + }); + + // 替换全部 + replaceAll.setOnAction(e -> { + inputControl.setText(inputControl.getText().replace(keyword.getText(), replaceValue.getText())); + fetch.handler(); + }); + + // 关闭 + close.setOnAction(e -> setVisible(false)); + } + } + + /** + * 行号文本域 + * + * @author 夜雨 + * @since 2022-07-12 13:05 + */ + private class LineNumber extends TextArea { + + /** 行号滚动面板 */ + private ScrollPane scrollPane; + + /** 字号 */ + private final double fontSize; + + /** 所属文本域滚动面板 */ + private final ScrollPane ownerScrollPane; + + public LineNumber(ScrollPane ownerScrollPane, double fontSize) { + this.fontSize = fontSize; + this.ownerScrollPane = ownerScrollPane; + this.ownerScrollPane.vvalueProperty().addListener((obs, o, newV) -> { + // 不可单向绑定,TextArea 内部需要调度 setVvalue,也不可双向绑定,会造成滚动错位 + scrollPane.setVvalue(newV.doubleValue()); + }); + getStyleClass().add(CSS.BORDER_L); + setEditable(false); + setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); + addEventFilter(MouseEvent.ANY, Event::consume); + addEventFilter(ContextMenuEvent.CONTEXT_MENU_REQUESTED, Event::consume); + } + + /** + * 行号渲染 + * + * @param size 行数 + */ + private void render(int size) { + clear(); + for (int i = 0; i < size; i++) { + appendText(String.valueOf(i + 1)); + if (wraps.containsKey(i)) { + // 发生换行 + appendText("\n".repeat(Math.max(0, wraps.get(i)))); + } + if (i < size - 1) { + appendText("\n"); + } + } + // 行号宽度 + int lineL = 0; + for (long i = size; i != 0; i *= .01) { + lineL++; + } + setPrefWidth(lineL * fontSize + 12); + } + + @Override + protected Skin createDefaultSkin() { + Skin skin = super.createDefaultSkin(); + try { + // 同步滚动 + scrollPane = Ref.getClassFieldValue(skin, TextAreaSkin.class, "scrollPane", ScrollPane.class); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.vvalueProperty().addListener((obs, o, n) -> scrollPane.setVvalue(ownerScrollPane.getVvalue())); + scrollPane.getContent().setCursor(Cursor.DEFAULT); + + SmoothScroll.scrollPaneV(scrollPane); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return skin; + } + } + + /** + * 重写样式 layoutChildren,让滚动面板适应本组件的功能组件注入 + * + * @author 夜雨 + * @since 2022-07-11 16:56 + */ + private static class TextAreaEditSkin extends TextAreaSkin { + + public TextAreaEditSkin(TextAreaEditor control) { + super(control); + } + + @Override + protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) { + getChildren().get(0).resizeRelocate(contentX, contentY, contentWidth, contentHeight); + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/TextAreaEditorField.java b/src/main/java/com/imyeyu/fx/ui/components/TextAreaEditorField.java new file mode 100644 index 0000000..ea9838b --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/TextAreaEditorField.java @@ -0,0 +1,173 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.bean.CallbackArg; +import com.imyeyu.java.ref.Ref; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.Scene; +import javafx.scene.control.Skin; +import javafx.scene.control.TextField; +import javafx.scene.control.skin.TextFieldSkin; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; +import javafx.stage.Stage; + +/** + * 复杂文本域编辑器 {@link TextAreaEditor} 的文本框显示方式,需要时弹出文本域编辑器 + * + * @author 夜雨 + * @since 2022-07-26 16:42 + */ +public class TextAreaEditorField extends TextField implements TimiFXUI { + + /** 显示编辑器事件 */ + private CallbackArg onShowEditorEvent; + + /** 编辑器窗体 */ + private final EditorStage editorStage; + + /** 标题 */ + private final StringProperty title; + + /** 默认构造 */ + public TextAreaEditorField() { + this(""); + } + + /** + * 构造文本编辑器型文本框(可打开文本编辑器) + * + * @param text 文本内容 + */ + public TextAreaEditorField(String text) { + super(text); + title = new SimpleStringProperty(); + + editorStage = new EditorStage(); + editorStage.titleProperty().bind(title); + editorStage.editor.textProperty().bindBidirectional(textProperty()); + editorStage.editor.editableProperty().bind(editableProperty()); + + getStyleClass().add(CSS.PADDING_N); + setPrefHeight(28); + } + + @Override + protected Skin createDefaultSkin() { + Skin skin = super.createDefaultSkin(); + if (skin instanceof TextFieldSkin textFieldSkin) { + try { + Pane textGroup = Ref.getFieldValue(textFieldSkin, "textGroup", Pane.class); + textGroup.setTranslateY(-.5); + + // 插入编辑器图标 + IconButton editor = new IconButton(TimiFXIcon.fromName("WRITING")).withBackground(); + editor.getStyleClass().add(CSS.BORDER_L); + editor.setCursor(Cursor.DEFAULT); + + BorderPane root = new BorderPane(); + BorderPane.setMargin(textGroup, new Insets(0, 0, 0, 4)); + root.setCenter(textGroup); + root.setRight(editor); + + textFieldSkin.getChildren().setAll(root); + + // ---------- 事件 ---------- + + editor.setOnAction(e -> { + if (onShowEditorEvent != null) { + onShowEditorEvent.handler(editorStage); + } + TimiFX.showCenter(getScene().getWindow(), editorStage); + }); + + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return skin; + } + + /** + * 获取显示编辑器回调事件 + * + * @return 显示编辑器回调事件 + */ + public CallbackArg getOnShowEditorEvent() { + return onShowEditorEvent; + } + + /** + * 设置显示编辑器回调事件,触发时窗体并未显示 + * + * @param onShowEditorEvent 显示编辑器回调事件 + */ + public void setOnShowEditorEvent(CallbackArg onShowEditorEvent) { + this.onShowEditorEvent = onShowEditorEvent; + } + + /** + * 获取编辑器的弹窗 + * + * @return 编辑器弹窗 + */ + public EditorStage getEditorStage() { + return editorStage; + } + + /** + * 获取编辑器标题 + * + * @return 编辑器标题 + */ + public String getTitle() { + return title.get(); + } + + /** + * 获取编辑器标题属性 + * + * @return 编辑器标题属性 + */ + public StringProperty titleProperty() { + return title; + } + + /** + * 设置编辑器标题 + * + * @param title 编辑器标题 + */ + public void setTitle(String title) { + this.title.set(title); + } + + /** + * 编辑器弹窗 + * + * @author 夜雨 + * @since 2022-07-26 16:54 + */ + public static class EditorStage extends Stage { + + /** 编辑器 */ + final TextAreaEditor editor; + + EditorStage() { + editor = new TextAreaEditor(); + editor.getStyleClass().add(CSS.BORDER_T); + + Scene scene = new Scene(editor); + scene.getStylesheets().addAll(CSS_STYLE, CSS_FONT); + setScene(scene); + getIcons().add(TimiFXIcon.iconFromName("WRITING")); + setWidth(850); + setHeight(620); + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/TextFlower.java b/src/main/java/com/imyeyu/fx/ui/components/TextFlower.java new file mode 100644 index 0000000..e926c51 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/TextFlower.java @@ -0,0 +1,334 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.TimiJava; +import javafx.scene.Node; +import javafx.scene.paint.Paint; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 文本流组件,支持插入超链文本,解析富文本:{@link #matcher(String)} + * + * @author 夜雨 + * @since 2022-09-05 10:41 + */ +public class TextFlower extends TextFlow implements TimiFXUI { + + /** 默认构造器 */ + public TextFlower() { + getStyleClass().add(CSS.MINECRAFT); + } + + /** + * 文本开始,添加一个制表符 + * + * @return 本实例 + */ + public TextFlower textStart() { + getChildren().add(new Text("\t")); + return this; + } + + /** + * 追加文本,左侧补充一个制表符,通常是段落开始 + * + * @param text 文本 + * @return 本实例 + */ + public TextFlower textStart(String text) { + getChildren().add(new Text("\t" + text)); + return this; + } + + /** + * 追加文本 + * + * @param text 文本 + * @return 本实例 + */ + public TextFlower text(String text) { + getChildren().add(new Text(text)); + return this; + } + + /** + * 追加文本,左侧补充空格 + * + * @param text 文本 + * @return 本实例 + */ + public TextFlower textLSP(String text) { + getChildren().add(new Text(" " + text)); + return this; + } + + /** + * 追加文本,右侧补充空格 + * + * @param text 文本 + * @return 本实例 + */ + public TextFlower textRSP(String text) { + getChildren().add(new Text(text + " ")); + return this; + } + + /** + * 追加链接文本 + * + * @param text 显示文本 + * @param link 访问链接 + * @return 本实例 + */ + public TextFlower link(String text, String link) { + getChildren().add(new XHyperlink(text, link)); + return this; + } + + /** + * 追加链接文本 + * + * @param icon 图标 + * @param text 显示文本 + * @param link 访问链接 + * @return 本实例 + */ + public TextFlower link(Node icon, String text, String link) { + getChildren().add(new XHyperlink(icon, text, link)); + return this; + } + + /** + * 追加链接,显示文本为链接 + * + * @param value 内容 + * @return 本实例 + */ + public TextFlower syncLink(String value) { + getChildren().add(new XHyperlink(value, value)); + return this; + } + + /** + * 设置最后一个文本节点为链接 + * + * @param link 链接 + * @return 本实例 + */ + public TextFlower asLink(String link) { + Node text = getChildren().remove(getChildren().size() - 1); + if (text instanceof Text t) { + getChildren().add(new XHyperlink(t.getText(), link)); + } + return this; + } + + /** + * 富文本匹配解析字符串,转义使用 '\' + *
+ *

格式标准: + *

    + *
  • 超链:[可选文本,连接]
  • + *
  • 图标:<可选颜色, timi-fx-icon 图标名称>
  • + *
  • 重点:`[可选样式,内容]`
  • + *
+ * + * @param value 字符串 + * @return 本实例 + */ + public TextFlower matcher(String value) { + if (TimiJava.isNotEmpty(value)) { + getChildren().addAll(RichMatcher.parse(value)); + } + return this; + } + + /** + * 富文本匹配 + * + * @author 夜雨 + * @since 2022-10-10 11:23 + */ + private static class RichMatcher { + + /** + * 匹配正则 + * + * @author 夜雨 + * @since 2022-10-10 11:58 + */ + private enum Regex { + + LINK("\\[(.*?)]"), + + ICON("<(.*?)>"), + + SPAN("`(.*?)`"); + + final Pattern pattern; + + Regex(String regex) { + this.pattern = Pattern.compile(regex); + } + } + + /** + * 重点内容样式 + * + * @author 夜雨 + * @since 2022-10-10 11:58 + */ + private enum Style { + + UNDERLINE("u", "underline"); + + private final String[] matches; + + Style(String... matches) { + this.matches = matches; + } + + static Style fromMatcher(String matcher) { + Style[] values = values(); + for (int i = 0; i < values.length; i++) { + String[] matches = values[i].matches; + for (int j = 0; j < matches.length; j++) { + if (matches[j].equalsIgnoreCase(matcher)) { + return values[i]; + } + } + } + return null; + } + } + + /** + * 解析富文本匹配 + * + * @param data 文本内容 + * @return 匹配节点列表 + */ + static List parse(String data) { + List result = new ArrayList<>(); + + Regex[] regexes = Regex.values(); + + Map nodeMap = new HashMap<>(); + + int[] insertI = {0}; + String value = data; + Matcher matcher; + for (int i = 0; i < regexes.length; i++) { + final int j = i; + matcher = regexes[i].pattern.matcher(value); + + value = matcher.replaceAll(matchResult -> { + if (matchResult.start() - 1 != -1) { + if (data.charAt(matchResult.start() - 1) == '\\') { + return matchResult.group(); + } + } + nodeMap.put(String.valueOf(insertI[0]), node(regexes[j], value(regexes[j], matchResult.group()))); + return "[" + insertI[0]++ + "]"; + }); + } + + // 二次解析 + matcher = Pattern.compile("\\[(.*?)]").matcher(value); + int end = 0; + while (matcher.find()) { + if (matcher.start() - 1 != -1) { + if (data.charAt(matcher.start() - 1) == '\\') { + continue; + } + } + if (end != matcher.start()) { + result.add(new Text(value.substring(end, matcher.start()))); + } + result.add(nodeMap.get(value(Regex.LINK, matcher.group()))); + end = matcher.end(); + } + result.add(new Text(value.substring(end))); + return result; + } + + /** + * 解析正则匹配值 + * + * @param regex 匹配正则 + * @param matcherResult 匹配结果 + * @return 匹配值 + */ + private static String value(Regex regex, String matcherResult) { + return switch (regex) { + case LINK, ICON, SPAN -> matcherResult.substring(1, matcherResult.length() - 1); + }; + } + + /** + * 解析节点 + * + * @param regex 匹配正则 + * @param value 匹配值 + * @return 节点 + */ + private static Node node(Regex regex, String value) { + return switch (regex) { + // 超链 + case LINK -> { + int sp = value.indexOf(","); + if (sp == -1) { + yield new XHyperlink(value.trim()); + } else { + while (value.charAt(sp - 1) == '\\') { + sp = value.indexOf(",", sp + 1); + } + yield new XHyperlink(value.substring(0, sp).trim(), value.substring(sp + 1).trim()); + } + } + // 图标 + case ICON -> { + Text icon; + int sp = value.lastIndexOf(","); + if (sp == -1) { + icon = TimiFXIcon.fromName(value); + } else { + icon = TimiFXIcon.fromName(value.substring(sp + 1).trim()); + icon.setFill(Paint.valueOf(value.substring(0, sp).trim())); + } + yield icon; + } + // 重点内容 + case SPAN -> { + int sp = value.indexOf(","); + if (sp == -1) { + yield new Text(value); + } else { + Text text = new Text(value.substring(sp + 1).trim()); + String[] styles = value.substring(0, sp).trim().split(" "); + for (int i = 0; i < styles.length; i++) { + Style style = Style.fromMatcher(styles[i]); + if (style == null) { + text.setFill(Paint.valueOf(styles[i])); + } else { + if (style == Style.UNDERLINE) { + text.setUnderline(true); + } + } + } + yield text; + } + } + }; + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/TimePicker.java b/src/main/java/com/imyeyu/fx/ui/components/TimePicker.java new file mode 100644 index 0000000..b924d05 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/TimePicker.java @@ -0,0 +1,201 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.utils.Time; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.geometry.Bounds; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.stage.Popup; + +import java.time.LocalDateTime; + +/** + * 时间选择器 + * + * @author 夜雨 + * @since 2022-11-09 14:25 + */ +public class TimePicker extends HBox implements TimiFXUI { + + private static final String STYLE_CLASS = "time-picker"; + + private final Popup popup; + private final TextField textField; + private final ListView hour, minute, second; + + /** 值 */ + private final IntegerProperty value; + + /** 默认构造器 */ + public TimePicker() { + value = new SimpleIntegerProperty(-1); + + textField = new TextField(); + textField.setEditable(false); + textField.setAlignment(Pos.CENTER); + textField.setPrefWidth(70); + textField.setOnContextMenuRequested(Event::consume); + IconButton button = new IconButton(TimiFXIcon.fromName("CLOCK")).withBackground(); + button.getStyleClass().add(CSS.BORDER_TRB); + + // 时间选择 + hour = new ListView<>(); + minute = new ListView<>(); + second = new ListView<>(); + + hour.getStyleClass().add(CSS.BORDER_RB); + minute.getStyleClass().add(CSS.BORDER_RB); + second.getStyleClass().add(CSS.BORDER_B); + + // 此刻 + Button now = new Button(TimiFXUI.MULTILINGUAL.text("now")); + now.getStyleClass().add(CSS.BORDER_L); + + BorderPane root = new BorderPane(); + BorderPane.setAlignment(now, Pos.CENTER_RIGHT); + root.setEffect(Shadow.POPUP); + root.setBorder(Stroke.DEFAULT); + root.setPrefSize(140, 211); + root.setBackground(BG.DEFAULT); + root.setCenter(new HBox(hour, minute, second)); + root.setBottom(now); + + popup = new Popup(); + popup.getContent().setAll(root); + button.setOnAction(e -> { + hour.scrollTo(hour.getSelectionModel().getSelectedIndex() - 3); + minute.scrollTo(minute.getSelectionModel().getSelectedIndex() - 3); + second.scrollTo(second.getSelectionModel().getSelectedIndex() - 3); + + Bounds bounds = textField.localToScreen(textField.getLayoutBounds()); + popup.setAutoHide(true); + popup.show(textField, bounds.getMinX() - 5, bounds.getMaxY() - 6); + }); + + getStyleClass().add(STYLE_CLASS); + setAlignment(Pos.CENTER_LEFT); + getChildren().addAll(textField, button); + + // ---------- 事件 ---------- + + for (int i = 0; i < 24; i++) { + hour.getItems().add(String.format("%02d", i)); + } + for (int i = 0; i < 60; i++) { + minute.getItems().add(String.format("%02d", i)); + } + second.getItems().addAll(minute.getItems()); + + // 时间滚动居中 + hour.getSelectionModel().select(0); + minute.getSelectionModel().select(0); + second.getSelectionModel().select(0); + + EventHandler middleScroll = e -> { + if (e.getSource() instanceof ListView list) { + if (e.getDeltaY() < 0) { + if (list.getSelectionModel().getSelectedIndex() < list.getItems().size() - 1) { + list.getSelectionModel().selectNext(); + } + } else { + if (0 < list.getSelectionModel().getSelectedIndex()) { + list.getSelectionModel().selectPrevious(); + } + } + list.scrollTo(list.getSelectionModel().getSelectedIndex() - 3); + + int h = hour.getSelectionModel().getSelectedIndex() * Time.HI; + int m = minute.getSelectionModel().getSelectedIndex() * Time.MI; + int s = second.getSelectionModel().getSelectedIndex() * Time.SI; + + setValue(h + m + s); + e.consume(); + } + }; + hour.addEventFilter(ScrollEvent.SCROLL, middleScroll); + minute.addEventFilter(ScrollEvent.SCROLL, middleScroll); + second.addEventFilter(ScrollEvent.SCROLL, middleScroll); + + TimiFX.hoverFocus(hour); + TimiFX.hoverFocus(minute); + TimiFX.hoverFocus(second); + + // 值监听 + value.addListener((obs, o, n) -> { + if (value.get() < 0 || Time.D < value.get()) { + throw new IllegalArgumentException("value must in [0, 86400000]"); + } + + LocalDateTime ldt = Time.toLocalDateTime(Time.today() + value.get()); + textField.setText("%02d:%02d:%02d".formatted(ldt.getHour(), ldt.getMinute(), ldt.getSecond())); + + // 选择器选中 + int s = value.get() / 1000; + int h = s / 60 / 60; + + hour.getSelectionModel().select(h); + minute.getSelectionModel().select(s / 60 - h * 60); + second.getSelectionModel().select(s % 60); + }); + + // 此刻 + now.setOnAction(e -> { + value.set((int) (Time.now() - Time.today())); + + LocalDateTime ldt = Time.toLocalDateTime(Time.now()); + hour.getSelectionModel().select(ldt.getHour()); + minute.getSelectionModel().select(ldt.getMinute()); + second.getSelectionModel().select(ldt.getSecond()); + + hour.scrollTo(hour.getSelectionModel().getSelectedIndex() - 3); + minute.scrollTo(minute.getSelectionModel().getSelectedIndex() - 3); + second.scrollTo(second.getSelectionModel().getSelectedIndex() - 3); + }); + + popup.setOnShown(e -> { + hour.scrollTo(hour.getSelectionModel().getSelectedIndex() - 3); + minute.scrollTo(minute.getSelectionModel().getSelectedIndex() - 3); + second.scrollTo(second.getSelectionModel().getSelectedIndex() - 3); + }); + + setValue(0); + } + + /** + * 获取当前值,距离今天零时的时间戳 + * + * @return 当前值 + */ + public int getValue() { + return value.get(); + } + + /** + * 获取值监听 + * + * @return 值监听 + */ + public IntegerProperty valueProperty() { + return value; + } + + /** + * 设置当前值,取值范围为一天时间戳 [0, 86400000] + * + * @param value 当前值 + */ + public void setValue(int value) { + this.value.set(value); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/TitleLabel.java b/src/main/java/com/imyeyu/fx/ui/components/TitleLabel.java new file mode 100644 index 0000000..4835768 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/TitleLabel.java @@ -0,0 +1,134 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.sun.javafx.scene.control.LabeledText; +import javafx.beans.binding.Bindings; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.ListChangeListener; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.Skin; +import javafx.scene.control.skin.LabelSkin; +import javafx.scene.paint.Paint; +import javafx.scene.shape.Rectangle; + +/** + * 标题标签,此组件左侧显示标题,并添加中线分割,产生内容分割并充当标题。组件默认最大化宽度 + * + * @author 夜雨 + * @since 2022-09-06 15:03 + */ +public class TitleLabel extends Label implements TimiFXUI { + + /** 标题与分割线间距 */ + protected DoubleProperty spacing; + + /** 分割线颜色 */ + protected ObjectProperty lineColor; + + /** 默认构造器 */ + public TitleLabel() { + this(""); + } + + /** + * 标准构造器 + * + * @param text 标题文本 + */ + public TitleLabel(String text) { + super(text); + + lineColor = new SimpleObjectProperty<>(Colorful.BORDER); + spacing = new SimpleDoubleProperty(6); + + setMaxWidth(Double.MAX_VALUE); + } + + @Override + protected Skin createDefaultSkin() { + Skin defaultSkin = super.createDefaultSkin(); + if (defaultSkin instanceof LabelSkin skin) { + Rectangle line = new Rectangle(); + line.setHeight(1); + line.fillProperty().bind(lineColor); + line.translateYProperty().bind(heightProperty().multiply(.5).subtract(1)); + Node node = skin.getChildren().get(0); + if (node instanceof LabeledText text) { + line.widthProperty().bind(Bindings.createDoubleBinding(() -> { + double textWidth = text.getLayoutBounds().getWidth(); + return getWidth() - textWidth - spacing.get(); + }, spacing, widthProperty(), text.layoutBoundsProperty())); + line.translateXProperty().bind(Bindings.createDoubleBinding(() -> { + double textWidth = text.getLayoutBounds().getWidth(); + return textWidth + spacing.get(); + }, spacing, text.layoutBoundsProperty())); + } + skin.getChildren().addListener((ListChangeListener) c -> { + if (skin.getChildren().get(0) != line) { + skin.getChildren().add(0, line); + } + }); + skin.getChildren().add(0, line); + } + return defaultSkin; + } + + /** + * 获取当前分割线颜色 + * + * @return 分割线颜色,默认 {@link TimiFX.Colorful#BORDER} + */ + public Paint getLineColor() { + return lineColor.get(); + } + + /** + * 设置分割线颜色 + * + * @param lineColor 分割线颜色 + */ + public void setLineColor(Paint lineColor) { + this.lineColor.set(lineColor); + } + + /** + * 获取分割线颜色监听 + * + * @return 分割线颜色监听 + */ + public ObjectProperty lineColorProperty() { + return lineColor; + } + + /** + * 获取当前标题文本和分割线的间距 + * + * @return 标题文本和分割线的间距,默认 6 + */ + public double getSpacing() { + return spacing.get(); + } + + /** + * 设置标题文本和分割线的间距 + * + * @param spacing 标题文本和分割线的间距 + */ + public void setSpacing(double spacing) { + this.spacing.set(spacing); + } + + /** + * 获取当前标题文本和分割线的间距监听 + * + * @return 标题文本和分割线的间距监听 + */ + public DoubleProperty spacingProperty() { + return spacing; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/ToggleIcon.java b/src/main/java/com/imyeyu/fx/ui/components/ToggleIcon.java new file mode 100644 index 0000000..c597ca7 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/ToggleIcon.java @@ -0,0 +1,215 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.utils.BgFill; +import com.imyeyu.java.TimiJava; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Skin; +import javafx.scene.control.ToggleButton; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Background; + +/** + * 图标选择状态按钮,可选图片、SVG 路径或 {@link com.imyeyu.fx.icon.TimiFXIcon} 的字体图标 + * + * @author 夜雨 + * @since 2022-05-18 10:16 + */ +public class ToggleIcon extends ToggleButton implements TimiFXUI { + + private static final String STYLE_CLASS = "toggle-icon"; + private static final Background BG_SELECTED = new BgFill("#99D1FF").build(); + + /** 选中图标 */ + protected final Node selected; + + /** 非选中图标 */ + protected final Node otherwise; + + /** 是否自适应尺寸 */ + protected final BooleanProperty autoSize; + + private Insets iconPadding, iconTextPadding; // 缓存单独图标内边距和图标文本混合的内边距 + + // ---------- 图片图标 ---------- + + /** + * 构造图片图标选择按钮 + * + * @param img 图片 + */ + public ToggleIcon(Image img) { + this(img, img); + } + + /** + * 构造图片图标切换按钮 + * + * @param selectedImg 已选图片 + * @param otherwiseImg 未选图片 + */ + public ToggleIcon(Image selectedImg, Image otherwiseImg) { + this(new ImageView(selectedImg), new ImageView(otherwiseImg)); + } + + // ---------- SVG 图标 ---------- + + /** + * 构造 SVG 图标选择按钮 + * + * @param svg SVG 路径 + */ + public ToggleIcon(String svg) { + this(new SVGIcon(svg)); + } + + /** + * 构造 SVG 图标选择按钮 + * + * @param selectedSVG 已选 SVG 路径 + * @param otherwiseSVG 未选 SVG 路径 + */ + public ToggleIcon(String selectedSVG, String otherwiseSVG) { + this(new SVGIcon(selectedSVG), new SVGIcon(otherwiseSVG)); + } + + /** + * 构造 SVG 图标选择按钮 + * + * @param icon SVG 图标 + */ + public ToggleIcon(SVGIcon icon) { + this(icon, icon); + } + + // ---------- 默认构造 ---------- + + /** + * 构造自定义节点选择按钮 + * + * @param icon 节点 + */ + public ToggleIcon(Node icon) { + this(icon, icon); + } + + /** + * 构造 SVG 图标选择按钮 + * + * @param selected 已选节点 + * @param otherwise 未选节点 + */ + public ToggleIcon(Node selected, Node otherwise) { + this.selected = selected; + this.otherwise = otherwise; + autoSize = new SimpleBooleanProperty(false); + + if (selected == otherwise) { + setGraphic(selected); + } else { + otherwise.getStyleClass().add("icon"); + graphicProperty().bind(Bindings.when(selectedProperty()).then(selected).otherwise(otherwise)); + } + selected.getStyleClass().add("icon"); + TimiFX.hoverOpacity(this); + + getStyleClass().setAll(CSS.MINECRAFT, STYLE_CLASS); + setAlignment(Pos.CENTER); + setMaxHeight(Double.MAX_VALUE); + backgroundProperty().bind(Bindings.when(selectedProperty()).then(BG_SELECTED).otherwise(BG.TRANSPARENT)); + + // 自适应尺寸、单独图标、图标文本混合时设置不同的内边距 + paddingProperty().bind(Bindings.createObjectBinding(() -> { + if (autoSize.get()) { + return Insets.EMPTY; + } else { + if (TimiJava.isEmpty(getText())) { + return iconPadding == null ? Insets.EMPTY : iconPadding; + } else { + return iconTextPadding == null ? Insets.EMPTY : iconTextPadding; + } + } + }, textProperty(), autoSize, skinProperty())); + } + + /** + * 添加按钮背景 + * + * @return 本实例 + */ + public ToggleIcon withBackground() { + return withBackground(null); + } + + /** + * 添加按钮背景 + * + * @param borderClass 边框类 + * @return 本实例 + */ + public ToggleIcon withBackground(String borderClass) { + getStyleClass().add(CSS.BG_BUTTON); + if (TimiJava.isNotEmpty(borderClass)) { + getStyleClass().add(borderClass); + } + setAlignment(Pos.CENTER); + + backgroundProperty().unbind(); + return this; + } + + /** + * 自适应尺寸,不使用内边距填充,图标尺寸决定组件尺寸 + * + * @return 本实例 + */ + public ToggleIcon autoSize() { + autoSize.set(true); + return this; + } + + /** + * 获取当前是否自适应尺寸,ture 时图标尺寸决定组件尺寸 + * + * @return true 为自适应尺寸 + */ + public boolean isAutoSize() { + return autoSize.get(); + } + + /** + * 设置是否自适应尺寸,ture 时图标尺寸决定组件尺寸 + * + * @param autoSize true 为自适应尺寸 + */ + public void setAutoSize(boolean autoSize) { + this.autoSize.set(autoSize); + } + + /** + * 获取自适应尺寸监听 + * + * @return 自适应尺寸监听 + */ + public BooleanProperty autoSizeProperty() { + return autoSize; + } + + @Override + protected Skin createDefaultSkin() { + Skin defaultSkin = super.createDefaultSkin(); + double h = getFont().getSize() * .382; + double v = h * .8; + double tv = h * .6; + iconPadding = new Insets(v); + iconTextPadding = new Insets(tv, h, tv, h); + return defaultSkin; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/TrayFX.java b/src/main/java/com/imyeyu/fx/ui/components/TrayFX.java new file mode 100644 index 0000000..516f9cf --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/TrayFX.java @@ -0,0 +1,395 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.io.IO; +import com.imyeyu.java.bean.CallbackArg; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.control.MenuItem; +import javafx.scene.layout.StackPane; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import javax.swing.SwingUtilities; +import java.awt.Image; +import java.awt.Point; +import java.awt.SystemTray; +import java.awt.Toolkit; +import java.awt.TrayIcon; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * JavaFX 系统托盘(单例),需要在 FX 线程运行后调用 + *
+ *     TrayFX trayFX = TrayFX.getInstance();
+ *     trayFX.getMenu().getItems().addAll(new MenuItem("menu"));
+ *     trayFX.show("icon.png");
+ * 
+ * + * @author 夜雨 + * @since 2021-10-30 17:27 + */ +public final class TrayFX implements TimiFXUI { + + private static final String SORT_KEY = "TIMI_FX_TRAY_SORT_KEY"; + private static final String STYLE_CLASS = "tray-menu"; + + private static TrayFX trayFX; + + private final Stage owner; + + /** 菜单寄主窗体 */ + private final Stage stage; + + private final StackPane root; + private final ContextMenu menu; + + /** 托盘对象 */ + private final SystemTray tray; + + /** 文本提示 */ + private final StringProperty toolTip; + + /** 显示监听 */ + private final BooleanProperty showing; + + /** 图标监听 */ + private final ObjectProperty icon; + + /** 托盘图标 */ + private TrayIcon trayIcon; + + private final List> showMenuListeners; + private final List> clickListeners; + + private TrayFX() { + tray = SystemTray.getSystemTray(); + + clickListeners = new ArrayList<>(); + showMenuListeners = new ArrayList<>(); + toolTip = new SimpleStringProperty(); + icon = new SimpleObjectProperty<>(); + showing = new SimpleBooleanProperty(false); + + // 嵌套舞台去除边框的同时不显示在任务栏 + Rectangle2D screen = Screen.getPrimary().getBounds(); + owner = new Stage(); + owner.initStyle(StageStyle.UTILITY); + owner.setOpacity(0); + owner.setX(screen.getMaxX() + 10); + owner.setY(screen.getMaxY() + 10); + + menu = new ContextMenu(); + menu.getStyleClass().add(STYLE_CLASS); + + root = new StackPane(); + + Scene scene = new Scene(root); + scene.getStylesheets().addAll(CSS_STYLE, CSS_FONT); + scene.setFill(null); + stage = new Stage(); + stage.setWidth(1); + stage.setHeight(1); + stage.setScene(scene); + stage.initOwner(owner); + stage.setResizable(false); + stage.setAlwaysOnTop(true); + stage.initStyle(StageStyle.TRANSPARENT); + + // 图标 + icon.addListener((obs, o, img) -> trayIcon.setImage(img)); + + // 失焦隐藏 + stage.focusedProperty().addListener((obs, o, isFocused) -> { + if (!isFocused) { + stage.hide(); + owner.hide(); + } + }); + + // 提示文本 + toolTip.addListener((obs, o, text) -> { + if (trayIcon != null) { + if (text != null && !text.trim().equals("")) { + trayIcon.setToolTip(text); + } else { + trayIcon.setToolTip(""); + } + } + }); + + // 排序 + menu.setOnShown(e -> menu.getItems().sort((o1, o2) -> { + Object o1v = o1.getProperties().get(SORT_KEY); + Object o2v = o2.getProperties().get(SORT_KEY); + int o1i = o1v == null ? 0 : (int) o1v; + int o2i = o2v == null ? 0 : (int) o2v; + return Integer.compare(o1i, o2i); + })); + } + + /** + * 添加菜单 + * + * @param menu 菜单 + */ + public void addMenu(MenuItem... menu) { + this.menu.getItems().addAll(menu); + } + + /** + * 添加菜单 + * + * @param sort 排序位置 + * @param menu 菜单 + */ + public void addMenu(int sort, MenuItem... menu) { + for (int i = 0; i < menu.length; i++) { + menu[i].getProperties().put(SORT_KEY, sort); + } + this.menu.getItems().addAll(menu); + } + + /** + * 获取菜单进行修改(添加菜单建议通过 {@link #addMenu(int, MenuItem...)},可以手动排序 + * + * @return 菜单 + */ + public ContextMenu getMenu() { + return menu; + } + + /** + * 获取根节点,修改这个节点的内容可以完全自定义右键菜单内容 + * + * @return 根节点 + */ + public StackPane getRoot() { + return root; + } + + /** + * 显示图标到托盘 + * + * @param path 图标位置 + */ + public void show(String path) { + try { + show(Toolkit.getDefaultToolkit().createImage(IO.resourceToBytes(getClass(), path))); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 显示图标到托盘 + * + * @param icon 图标 + */ + public void show(Image icon) { + showing.set(true); + try { + trayIcon = new TrayIcon(icon); + // 点击事件 + trayIcon.addMouseListener(new MouseAdapter() { + + @Override + public void mouseReleased(MouseEvent e) { + Platform.runLater(() -> { + for (int i = 0; i < clickListeners.size(); i++) { + clickListeners.get(i).handler(e); + } + if (SwingUtilities.isRightMouseButton(e)) { + Point p = e.getLocationOnScreen(); + owner.show(); + stage.setX(p.getX()); + stage.setY(p.getY()); + stage.show(); + stage.setAlwaysOnTop(true); + stage.requestFocus(); + menu.setX(p.getX()); + menu.setY(p.getY()); + menu.show(stage); + stage.sizeToScene(); + for (int i = 0; i < showMenuListeners.size(); i++) { + showMenuListeners.get(i).handler(stage); + } + } + }); + } + }); + tray.add(trayIcon); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * 发送系统通知 + * + * @param title 标题 + * @param content 内容 + * @param type 类型 + */ + public void sendMessage(String title, String content, TrayIcon.MessageType type) { + trayIcon.displayMessage(title, content, type); + } + + /** 从托盘移除图标(应主动调用,操作系统不会监听程序是否还在运行) */ + public void remove() { + if (trayIcon != null) { + tray.remove(trayIcon); + trayIcon = null; + } + showing.set(false); + } + + /** + * 获取单例对象 + * + * @return 单例对象 + */ + public static synchronized TrayFX getInstance() { + if (!SystemTray.isSupported()) { + throw new UnsupportedOperationException("The OS is unsupported tray icon."); + } + if (trayFX == null) { + trayFX = new TrayFX(); + } + return trayFX; + } + + /** + * 获取提示文本 + * + * @return 提示文本 + */ + public String getToolTip() { + return toolTip.get(); + } + + /** + * 设置提示文本,需在 {@link #show(Image)} 或 {@link #show(String)} 之后调用才有效 + * + * @param text 文本 + */ + public void setToolTip(String text) { + toolTip.set(text); + } + + /** + * 获取提示文本监听 + * + * @return 提示文本监听 + */ + public StringProperty toolTipProperty() { + return toolTip; + } + + /** + * 设置图标 + * + * @param path 图标位置 + */ + public void setIcon(String path) { + try { + setIcon(Toolkit.getDefaultToolkit().createImage(IO.resourceToBytes(getClass(), path))); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 设置图标 + * + * @param image AWT 图片 + */ + public void setIcon(Image image) { + icon.set(image); + } + + /** + * 获取当前图标 + * + * @return 图标 + */ + public Image getIcon() { + return icon.get(); + } + + /** + * 获取图标监听 + * + * @return 图标监听 + */ + public ObjectProperty iconProperty() { + return icon; + } + + /** + * 获取是否正在显示托盘图标 + * + * @return true 为正在显示托盘图标 + */ + public boolean isShowing() { + return showing.get(); + } + + /** + * 获取正在显示托盘图标监听 + * + * @return 正在显示托盘图标监听 + */ + public ReadOnlyBooleanProperty showingProperty() { + return showing; + } + + /** + * 添加点击回调 + * + * @param listener 点击监听 + */ + public void addClickListener(CallbackArg listener) { + clickListeners.add(listener); + } + + /** + * 添加显示菜单回调 + * + * @param listener 点击监听 + */ + public void addShowMenuListener(CallbackArg listener) { + showMenuListeners.add(listener); + } + + /** + * 获取托盘图标 + * + * @return 托盘图标 + */ + public TrayIcon getTrayIcon() { + return trayIcon; + } + + /** + * 获取托盘对象 + * + * @return 托盘对象 + */ + public SystemTray getTray() { + return tray; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/VersionLabel.java b/src/main/java/com/imyeyu/fx/ui/components/VersionLabel.java new file mode 100644 index 0000000..edb44de --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/VersionLabel.java @@ -0,0 +1,208 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.task.RunAsync; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.utils.Encoder; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; + +import java.awt.Desktop; +import java.io.IOException; +import java.net.URI; + +/** + * 版本标签,此组件用于显示版本、检查更新和可更新时点击去向,目前可能只适合我使用 + * + * @author 夜雨 + * @since 2022-02-19 18:42 + */ +public abstract class VersionLabel extends VBox implements TimiFXUI, TimiFXUI.Colorful { + + /** + * 状态 + * + * @author 夜雨 + * @since 2022-11-27 16:13 + */ + protected enum Status { + + /** 一般 */ + NORMAL(BLACK), + + /** 正在检查 */ + CHECKING(ORANGE), + + /** 存在更新 */ + HAS_UPDATE(GREEN), + + /** 错误 */ + ERROR(RED); + + Color textColor; + + Status(Color textColor) { + this.textColor = textColor; + } + + /** + * 设置该状态的文本颜色 + * + * @param textColor 颜色 + */ + public void setTextColor(Color textColor) { + this.textColor = textColor; + } + } + + /** 更新链接 */ + protected String updateURL; + + /** 版本标签 */ + protected Label version; + + /** 其他内容标签 */ + protected Label content; + + /** 状态 */ + protected ObjectProperty status; + + /** 默认构造 */ + public VersionLabel() { + this(""); + } + + /** + * 默认构造 + * + * @param text 显示版本 + */ + public VersionLabel(String text) { + status = new SimpleObjectProperty<>(Status.NORMAL); + + version = new Label(); + version.setText(text); + version.setWrapText(true); + version.textFillProperty().bind(Bindings.createObjectBinding(() -> status.get().textColor, status)); + + content = new Label(); + content.managedProperty().bind(content.textProperty().isNotEmpty()); + + setSpacing(3); + setAlignment(Pos.CENTER); + getChildren().addAll(version, content); + } + + /** + * 检查版本更新 + * + * @param nowVersion 当前版本 + */ + public void checkVersion(String nowVersion) { + status.set(Status.CHECKING); + version.setCursor(Cursor.DEFAULT); + version.setOnMouseClicked(null); + + // ---------- 事件 ---------- + + new RunAsync() { + + @Override + protected T call() { + return VersionLabel.this.run(); + } + + @Override + protected void onFinish(T t) { + try { + String version = onReturn(t); + if (version.equals(nowVersion)) { + // 无新版本 + VersionLabel.this.version.setText(nowVersion); + status.set(Status.NORMAL); + } else { + // 存在新版本 + VersionLabel.this.version.setText(VersionLabel.this.updateText(version)); + VersionLabel.this.version.setCursor(Cursor.HAND); + VersionLabel.this.version.underlineProperty().bind(VersionLabel.this.version.hoverProperty()); + VersionLabel.this.version.setOnMouseClicked(event -> { + try { + Desktop dp = Desktop.getDesktop(); + if (dp.isSupported(Desktop.Action.BROWSE)) { + dp.browse(URI.create(Encoder.url(updateURL))); + } + } catch (IOException e) { + e.printStackTrace(); + } + }); + status.set(Status.HAS_UPDATE); + } + } catch (RuntimeException e) { + version.setText(e.getMessage()); + status.set(Status.ERROR); + } + } + + @Override + protected void onException(Throwable e) { + version.setText(TimiFXUI.MULTILINGUAL.textArgs("version.fail", nowVersion)); + version.setOnMouseClicked(event -> checkVersion(nowVersion)); + e.printStackTrace(); + } + }.start(); + } + + /** + * 执行查询版本 + * + * @return 执行返回 + */ + protected abstract T run() throws RuntimeException; + + /** + * 执行返回 + * + * @param t 返回数据 + * @return 具体版本号 + */ + protected abstract String onReturn(T t) throws RuntimeException; + + /** + * 存在更新时执行 + * + * @param newVersion 版本 + * @return 显示文本 + */ + protected abstract String updateText(String newVersion); + + /** + * 执行异常的显示文本 + * + * @param e 异常 + * @return 显示文本 + */ + protected abstract String failText(Throwable e); + + /** + * 获取更新链接 + * + * @return 更新链接 + */ + public String getUpdateURL() { + return updateURL; + } + + /** + * 设置更新链接 + * + * @param updateURL 更新链接 + */ + public void setUpdateURL(String updateURL) { + this.updateURL = updateURL; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/XHyperlink.java b/src/main/java/com/imyeyu/fx/ui/components/XHyperlink.java new file mode 100644 index 0000000..52ece2c --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/XHyperlink.java @@ -0,0 +1,133 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.TimiJava; +import com.imyeyu.utils.Encoder; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; + +import java.awt.Desktop; +import java.io.IOException; +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 超链标签 + * + *
+ *     new XHyperlink("个人博客", "https://www.imyeyu.net");
+ * 
+ * + * @author 夜雨 + * @since 2022-08-30 10:51 + */ +public class XHyperlink extends Label implements TimiFXUI, TimiFXUI.Colorful { + + private static final Pattern URL_PATTERN = Pattern.compile("https?://(www\\.)?[-a-zA-Z\\d@:%._+~#=]{1,256}\\.[a-zA-Z\\d()]{1,6}\\b([-a-zA-Z\\d()@:%_+.~#?&/=]*)"); + + /** 链接监听 */ + protected final StringProperty url; + + /** 默认构造器 */ + public XHyperlink() { + this("", ""); + } + + /** + * 构造器,链接和文本一致 + * + * @param url 链接 + */ + public XHyperlink(String url) { + this(url, url); + } + + /** + * 构造器 + * + * @param text 显示文本 + * @param url 链接 + */ + public XHyperlink(String text, String url) { + this(null, text, url); + } + + /** + * 构造器 + * + * @param icon 显示图标 + * @param text 显示文本 + * @param url 链接 + */ + public XHyperlink(Node icon, String text, String url) { + super(text); + + this.url = new SimpleStringProperty(url); + + setCursor(Cursor.HAND); + setGraphic(icon); + setTextFill(FOCUSED_DEFAULT); + underlineProperty().bind(hoverProperty()); + + addEventFilter(MouseEvent.MOUSE_CLICKED, e -> { + if (e.getButton() == MouseButton.PRIMARY) { + if (TimiJava.isNotEmpty(this.url.get())) { + Matcher matcher = URL_PATTERN.matcher(this.url.get()); + if (matcher.find()) { + try { + Desktop dp = Desktop.getDesktop(); + if (dp.isSupported(Desktop.Action.BROWSE)) { + dp.browse(URI.create(Encoder.url(this.url.get()))); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + } + } + }); + } + + /** + * 同步设置链接和文本 + * + * @param textUrl 显示文本和链接 + */ + public void sync(String textUrl) { + setText(textUrl); + url.set(textUrl); + } + + /** + * 获取当前链接 + * + * @return 链接 + */ + public String getUrl() { + return url.get(); + } + + /** + * 设置链接 + * + * @param url 链接 + */ + public void setUrl(String url) { + this.url.set(url); + } + + /** + * 获取链接监听 + * + * @return 链接监听 + */ + public StringProperty urlProperty() { + return url; + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/ui/components/XPagination.java b/src/main/java/com/imyeyu/fx/ui/components/XPagination.java new file mode 100644 index 0000000..e49695f --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/XPagination.java @@ -0,0 +1,337 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import javafx.beans.binding.Bindings; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.beans.value.ChangeListener; +import javafx.event.EventHandler; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; + +import java.util.ArrayList; +import java.util.List; + +/** + * 分页组件,支持省略页,滚动页,如 [<][1]..[5][6][7][8][9]..[20][>] + *
+ *     XPagination pagination = new XPagination();
+ *     pagination.setSize(10);    // 单页数据量
+ *     pagination.setLength(200); // 总数据量
+ *     pagination.indexProperty((obs, o, newIndex) -> {
+ *        // 监听激活下标
+ *     });
+ *     pagination.setIndex(6); // 激活页下标(0 开始,这是第 7 页)
+ * 
+ * + * @author 夜雨 + * @since 2021-12-22 15:25 + */ +public class XPagination extends HBox implements TimiFXUI { + + private static final String STYLE_CLASS = "x-pagination"; + + /** 阻止取消选择 */ + public static final EventHandler EVENT_TOGGLE_BUTTON = e -> { + if (e.getSource() instanceof ToggleButton btn && btn.isSelected()) { + e.consume(); + } + }; + + private final IconButton prev, next; + + // 步进翻页动态下标 + private int prevI, nextI; + + private final LongProperty lp; // lengthProperty 总数据量 [0, N] + private final IntegerProperty ip; // indexProperty 激活下标 [0, chunkProperty.value] + private final IntegerProperty sp; // sizeProperty 单页数量 [1, N] + private final IntegerProperty cp; // chunkProperty 页面数量 [1, N] + + /** 默认构造 */ + public XPagination() { + // 基本参数 + lp = new SimpleLongProperty(); + ip = new SimpleIntegerProperty(); + sp = new SimpleIntegerProperty(); + cp = new SimpleIntegerProperty(); + + // 更新页面大小和总数据量时重新计算页面数量 + cp.bind(Bindings.createIntegerBinding(() -> { + long total = lp.get(); + int page = sp.get(); + return (int) Math.ceil(1D * total / page); + }, sp, lp)); + + // 页面组 + List pageButtons = new ArrayList<>(); + PageButton tb; + + // 上一页 + prev = new IconButton(TimiFXIcon.fromName("ARROW_1_W")).withBackground(); + prev.getStyleClass().add(CSS.BORDER_N); + prev.setMaxHeight(Double.MAX_VALUE); + prev.disableProperty().bind(ip.isEqualTo(0)); + getChildren().add(prev); + + // 前置页 [1, 6] + for (int i = 0; i < 6; i++) { + tb = new PageButton(); + tb.indexProperty.set(i); + if (0 < i) { + + // 第一页保持显示,非第一页显示条件: + // 1. 总页数小于 8,页码大于 i + // 2. 总页数大于等于 8,激活下标小于 4(1 - 4 页) + tb.visibleProperty().bind(cp.greaterThan(i).and(cp.lessThan(8)).or(cp.greaterThan(7).and(ip.lessThan(4)))); + } else { + tb.getStyleClass().add(CSS.BORDER_LR); + } + pageButtons.add(tb); + getChildren().add(tb); + } + { + // 左省略,总页数大于 7(有中间页),激活下标大于 3 时显示(第五页) + Label leftEllipsis = new Label(".."); + leftEllipsis.setAlignment(Pos.CENTER); + leftEllipsis.setPrefWidth(32); + leftEllipsis.setBorder(Stroke.RIGHT); + leftEllipsis.setMaxHeight(Double.MAX_VALUE); + leftEllipsis.visibleProperty().bind(cp.greaterThan(7).and(ip.greaterThan(3))); + leftEllipsis.managedProperty().bind(leftEllipsis.visibleProperty()); + getChildren().add(leftEllipsis); + + // 中间页,显示条件:总页数大于 8 ,激活下标大于 3 且小于总页数 - 4(小于 3 时前置页处理,大于总页数 - 4 时后置页处理) + for (int i = 0; i < 5; i++) { + tb = new PageButton(); + tb.indexProperty.bind(ip.add(i - 2)); // 动态数值 + tb.visibleProperty().bind(cp.greaterThan(8).and(ip.greaterThan(3).and(ip.lessThan(cp.subtract(4))))); + + pageButtons.add(tb); + getChildren().add(tb); + } + + // 右省略,显示条件:总页数大于 7,激活下标小于总页数 - 4(大于总页数 - 4 时后置页处理,不需要省略) + Label rightEllipsis = new Label(".."); + rightEllipsis.setAlignment(Pos.CENTER); + rightEllipsis.setPrefWidth(32); + rightEllipsis.setMaxHeight(Double.MAX_VALUE); + rightEllipsis.setBorder(Stroke.RIGHT); + rightEllipsis.visibleProperty().bind(cp.greaterThan(7).and(ip.lessThan(cp.subtract(4)))); + rightEllipsis.managedProperty().bind(rightEllipsis.visibleProperty()); + getChildren().add(rightEllipsis); + } + + // 后置页 + for (int i = 0; i < 6; i++) { + tb = new PageButton(); + tb.indexProperty.bind(cp.add(i - 6)); // 动态数值 + if (i == 5) { + // 页数达到 7 时最后一页始终显示 + tb.visibleProperty().bind(cp.greaterThan(6)); + } else { + // 其他页显示条件:总页数大于 7,激活下标大于总页数 - 5(倒数第四页) + tb.visibleProperty().bind(cp.greaterThan(7).and(ip.greaterThan(cp.subtract(5)))); + } + pageButtons.add(tb); + getChildren().add(tb); + } + + // 下一页 + next = new IconButton(TimiFXIcon.fromName("ARROW_1_E")).withBackground(); + next.setMaxHeight(Double.MAX_VALUE); + next.getStyleClass().add(CSS.BORDER_N); + next.disableProperty().bind(ip.isEqualTo(cp.subtract(1)).or(cp.isEqualTo(0))); + + getStyleClass().add(STYLE_CLASS); + setBorder(Stroke.DEFAULT); + setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + setAlignment(Pos.BOTTOM_CENTER); + getChildren().add(next); + + // ---------- 事件 ---------- + + new ToggleGroup().getToggles().addAll(pageButtons); + for (int i = 0; i < pageButtons.size(); i++) { + // 阻止取消选择 + pageButtons.get(i).addEventFilter(MouseEvent.MOUSE_PRESSED, EVENT_TOGGLE_BUTTON); + } + + // 数据变动更新 + ChangeListener paramsListener = (obs, o, n) -> { + // 步进翻页 + prevI = ip.get() - 1; + nextI = ip.get() + 1; + // 重置激活页 + if (cp.get() - 1 < ip.get()) { + ip.set(0); + } + // 主动选中 + for (int i = 0; i < pageButtons.size(); i++) { + // 分页存在预设页码,只作触发事件用(如前置页的第五第六页),需要主动计算激活的按钮 + if (ip.get() == pageButtons.get(i).indexProperty.get() && pageButtons.get(i).isVisible()) { + pageButtons.get(i).setSelected(true); + return; + } + } + }; + sp.addListener(paramsListener); + ip.addListener(paramsListener); + lp.addListener(paramsListener); + + // 步进翻页 + prev.setOnAction(e -> ip.set(prevI)); + next.setOnAction(e -> ip.set(nextI)); + } + + /** 选择上一页 */ + public void prev() { + prev.fire(); + } + + /** 选择下一页 */ + public void next() { + next.fire(); + } + + /** + * 设置激活页下标,取值范围 [0, {@link #getChunk()}] + * + * @param index 激活页下标 + */ + public void setIndex(int index) { + ip.set(index); + } + + /** + * 获取当前激活页下标 + * + * @return 当前激活页下标 + */ + public int getIndex() { + return ip.get(); + } + + /** + * 获取激活页监听 + * + * @return 激活页监听 + */ + public IntegerProperty indexProperty() { + return ip; + } + + /** + * 获取当前页面数量 + * + * @return 当前页面数量 + */ + public int getChunk() { + return cp.get(); + } + + /** + * 获取页面数量监听 + * + * @return 页面数量监听 + */ + public ReadOnlyIntegerProperty chunkProperty() { + return cp; + } + + /** + * 设置单页数量,取值范围 [1, Integer.MAX_VALUE] + * + * @param size 单页数量 + */ + public void setSize(int size) { + if (size < 1) { + throw new IllegalArgumentException("page size range of [1, Integer.MAX_VALUE]"); + } + sp.set(size); + } + + /** + * 当前单页数量 + * + * @return 单页数量 + */ + public int getSize() { + return sp.get(); + } + + /** + * 获取单页数量监听 + * + * @return 单页数量监听 + */ + public IntegerProperty sizeProperty() { + return sp; + } + + /** + * 设置总数据量,取值范围 [0, Long.MAX_VALUE] + * + * @param length 总数据量 + */ + public void setLength(long length) { + if (length < 0) { + throw new IllegalArgumentException("length range of [0, Long.MAX_VALUE]"); + } + lp.set(length); + } + + /** + * 获取当前总数据量 + * + * @return 当前总数据量 + */ + public long getLength() { + return lp.get(); + } + + /** + * 获取总数据大小监听 + * + * @return 总数据大小监听 + */ + public LongProperty lengthProperty() { + return lp; + } + + /** + * 页面按钮,分页组件内部调度 + * + * @author 夜雨 + * @since 2022-01-27 01:42 + */ + private class PageButton extends ToggleButton implements TimiFXUI { + + /** 当前页码 */ + final IntegerProperty indexProperty; + + public PageButton() { + indexProperty = new SimpleIntegerProperty(); + + getStyleClass().addAll(CSS.BORDER_R, CSS.BG_BUTTON); + // 页码即显示内容 + textProperty().bind(indexProperty.add(1).asString()); + managedProperty().bind(visibleProperty()); + // 选中更新激活下标 + selectedProperty().addListener((obs, o, isSelected) -> { + if (isSelected) { + XPagination.this.ip.set(indexProperty.get()); + } + }); + } + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/XTabPane.java b/src/main/java/com/imyeyu/fx/ui/components/XTabPane.java new file mode 100644 index 0000000..033bf8e --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/XTabPane.java @@ -0,0 +1,113 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.java.bean.CallbackArg; +import com.imyeyu.java.ref.Ref; +import javafx.animation.TranslateTransition; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Skin; +import javafx.scene.control.TabPane; +import javafx.scene.control.skin.TabPaneSkin; +import javafx.scene.layout.StackPane; +import javafx.util.Duration; + +import java.util.List; + +/** + * 选项卡,简化了选区样式,以及在选项卡右侧添加了新增选项卡按钮,可让用户直接添加 + * + * @author 夜雨 + * @since 2022-07-24 10:54 + */ +public class XTabPane extends TabPane implements TimiFXUI, TimiFXUI.Colorful { + + /** 添加按钮 */ + protected final IconButton add; + + /** 默认构造 */ + public XTabPane() { + add = new IconButton(TimiFXIcon.fromName("PLUS")).withBackground(); + add.getStyleClass().add(CSS.BORDER_RB); + add.setPrefWidth(20); + } + + /** + * 获取添加按钮 + * + * @return 添加按钮 + */ + public IconButton getAdd() { + return add; + } + + @Override + protected Skin createDefaultSkin() { + Skin skin = super.createDefaultSkin(); + if (skin instanceof TabPaneSkin tabPaneSkin) { + try { + StackPane tabHeaderArea = Ref.getFieldValue(tabPaneSkin, "tabHeaderArea", StackPane.class); + StackPane headersRegion = Ref.getFieldValue(tabHeaderArea, "headersRegion", StackPane.class); + StackPane headersBackground = Ref.getFieldValue(tabHeaderArea, "headerBackground", StackPane.class); + + // 添加按钮 + TranslateTransition transition = new TranslateTransition(); + transition.setNode(add); + transition.setDuration(Duration.millis(150)); + headersRegion.widthProperty().addListener((obs, oldWidth, newWidth) -> { + if (oldWidth.doubleValue() < newWidth.doubleValue()) { + transition.setFromX(add.getTranslateX()); + transition.setToX(newWidth.intValue()); + transition.play(); + } else { + add.setTranslateX(newWidth.intValue()); + } + }); + add.prefHeightProperty().bind(headersBackground.heightProperty()); + StackPane.setAlignment(add, Pos.CENTER_LEFT); + headersBackground.getChildren().add(add); + + // 关闭按钮调整 + CallbackArg resizeCloseButton = tabHeaderSkin -> { + try { + if (tabHeaderSkin instanceof StackPane tabHeaderPane) { + StackPane closeBtn = Ref.getFieldValue(tabHeaderSkin, "closeBtn", StackPane.class); + closeBtn.setPrefWidth(18); + closeBtn.getStyleClass().clear(); + + IconButton icon = new IconButton(TimiFXIcon.fromName("FAIL", GRAY)); + icon.setAlignment(Pos.CENTER_LEFT); + icon.setMouseTransparent(true); + icon.prefWidthProperty().bind(closeBtn.widthProperty()); + icon.minHeightProperty().bind(tabHeaderPane.heightProperty()); + StackPane.setMargin(icon, new Insets(0, 12, 0, 0)); + + closeBtn.getChildren().add(icon); + } + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + }; + ObservableList tabList = headersRegion.getChildren(); + for (int i = 0; i < tabList.size(); i++) { + resizeCloseButton.handler(tabList.get(i)); + } + headersRegion.getChildren().addListener((ListChangeListener) c -> { + if (c.next()) { + List list = c.getAddedSubList(); + for (int i = 0; i < list.size(); i++) { + resizeCloseButton.handler(list.get(i)); + } + } + }); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return skin; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/XTreeView.java b/src/main/java/com/imyeyu/fx/ui/components/XTreeView.java new file mode 100644 index 0000000..5235fef --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/XTreeView.java @@ -0,0 +1,45 @@ +package com.imyeyu.fx.ui.components; + +import com.imyeyu.fx.utils.SmoothScroll; +import javafx.collections.ObservableList; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; + +/** + * 不显示根节点的树形结构,实现多个根节点 + * + * @author 夜雨 + * @since 2021-04-26 01:34 + */ +public class XTreeView extends TreeView { + + private final TreeItem dummyRoot = new TreeItem<>(); + + /** 默认构造 */ + public XTreeView() { + dummyRoot.setExpanded(true); + setRoot(dummyRoot); + setShowRoot(false); + // 平滑滚动 + SmoothScroll.virtual(this); + } + + /** + * 设置根节点 + * + * @param roots 根节点 + */ + @SafeVarargs + public final void setRoots(TreeItem... roots) { + dummyRoot.getChildren().addAll(roots); + } + + /** + * 获取根节点列表 + * + * @return 根节点列表 + */ + public ObservableList> getRoots() { + return dummyRoot.getChildren(); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlert.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlert.java new file mode 100644 index 0000000..f66f926 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlert.java @@ -0,0 +1,448 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.utils.ScreenFX; +import com.imyeyu.java.bean.CallbackArg; +import com.imyeyu.java.bean.CallbackArgReturn; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.image.Image; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.stage.Modality; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.Window; +import javafx.stage.WindowEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * 抽象弹窗 + * + * @author 夜雨 + * @since 2022-01-07 09:24 + */ +public abstract class AbstractAlert extends Stage implements TimiFXUI, TimiFXUI.Colorful { + + /** 默认按钮边距 */ + protected static final Insets PADDING_BUTTON = new Insets(12, 16, 12, 16); + + /** 默认内容边距 */ + protected static final Insets PADDING_CONTENT = new Insets(8, 16, 8, 16); + + /** 左侧按钮 */ + protected final HBox leftButtons; + + /** 中部按钮 */ + protected final HBox centerButtons; + + /** 右侧按钮 */ + protected final HBox rightButtons; + + /** 根面板 */ + protected final BorderPane root; + + /** 按钮面板,{@link #leftButtons}、{@link #centerButtons} 、{@link #rightButtons} 在此面板中 */ + protected final BorderPane btnPane; + + private final ObjectProperty typeProperty; + private final List> shownListeners; + + private AlertButton.Action action; + private CallbackArgReturn onActionEvent; + + /** 窗体尺寸是否适应场景尺寸,显示前设置有效,默认 true */ + protected boolean enableSizeToScene = true; + + /** 默认构造 */ + public AbstractAlert() { + typeProperty = new SimpleObjectProperty<>(); + shownListeners = new ArrayList<>(); + action = AlertButton.Action.CANCEL; // 默认取消 + + // 按钮布局 + leftButtons = new HBox(6); + centerButtons = new HBox(6); + rightButtons = new HBox(6); + + leftButtons.setAlignment(Pos.CENTER_LEFT); + centerButtons.setAlignment(Pos.CENTER); + rightButtons.setAlignment(Pos.CENTER_RIGHT); + + // 根布局 + root = new BorderPane(); + root.setBorder(Stroke.TOP); + root.setBottom(btnPane = new BorderPane() {{ + setPadding(PADDING_BUTTON); + setLeft(leftButtons); + setCenter(centerButtons); + setRight(rightButtons); + }}); + + Scene scene = new Scene(root); + scene.getStylesheets().addAll(CSS_STYLE, CSS_FONT); + initModality(Modality.WINDOW_MODAL); + setScene(scene); + + // ---------- 事件 ---------- + + // 显示 + setOnShown(e -> { + if (enableSizeToScene) { + sizeToScene(); + } + callShownListeners(e); + }); + // 类型变更 + typeProperty.addListener((obs, o, newType) -> { + if (newType != null) { + getIcons().setAll(newType.icon); + setTitle(newType.title); + } + }); + + layout(root); + + addEventFilter(KeyEvent.KEY_RELEASED, e -> { + boolean control = e.isControlDown(); + boolean shift = e.isShiftDown(); + boolean alt = e.isAltDown(); + KeyCode code = e.getCode(); + + if (!control && !shift && !alt) { + if (code == KeyCode.ESCAPE) { + onEscape(); + } + } + }); + } + + /** + * 回调显示监听 + * + * @param e 显示事件 + */ + private void callShownListeners(WindowEvent e) { + for (int i = 0; i < shownListeners.size(); i++) { + shownListeners.get(i).handler(e); + } + } + + /** 默认 ESC 键关闭 */ + protected void onEscape() { + close(); + } + + /** + * 方便匿名内部类的布局完成回调 + * + * @param root 根布局 + */ + protected void layout(BorderPane root) { + // 子类实现 + } + + /** + * 自适应窗体尺寸 + * + * @return 本实例 + */ + public AbstractAlert autoSize() { + sizeToScene(); + return this; + } + + /** + * 相对居中显示,不越出父级标题 + * + * @param owner 父级窗体 + */ + public void showRelativeCenter(Window owner) { + if (getOwner() == null) { + initOwner(owner); + } + setOnShown(e -> { + TimiFX.relativeCenter(owner, this); + callShownListeners(e); + }); + show(); + } + + /** + * 相对居中显示并等待,不越出父级标题 + * + * @param owner 父级窗体 + */ + public void showAwaitRelativeCenter(Window owner) { + if (getOwner() == null) { + initOwner(owner); + } + setOnShown(e -> { + TimiFX.relativeCenter(owner, this); + callShownListeners(e); + }); + showAndWait(); + } + + /** 相对于主屏幕中间显示 */ + public void showRelativeCenter4PrimaryScreen() { + showRelativeCenter4Screen(ScreenFX.primary); + } + + /** + * 相对于屏幕中间显示 + * + * @param screen 屏幕 + */ + public void showRelativeCenter4Screen(Screen screen) { + initModality(Modality.APPLICATION_MODAL); + setOnShown(e -> { + TimiFX.relativeCenter4Screen(screen, this); + callShownListeners(e); + }); + show(); + } + + /** 相对于主屏幕中间显示并等待 */ + public void showAwaitRelativeCenter4PrimaryScreen() { + showAwaitRelativeCenter4Screen(ScreenFX.primary); + } + + /** + * 相对于屏幕中间显示并等待 + * + * @param screen 屏幕 + */ + public void showAwaitRelativeCenter4Screen(Screen screen) { + initModality(Modality.APPLICATION_MODAL); + setOnShown(e -> { + TimiFX.relativeCenter4Screen(screen, this); + callShownListeners(e); + }); + showAndWait(); + } + + /** 清除所有按钮 */ + public void clearButton() { + leftButtons.getChildren().clear(); + centerButtons.getChildren().clear(); + rightButtons.getChildren().clear(); + } + + /** + * 设置弹窗按钮,使用按钮默认位置 + * + * @param buttons 弹窗按钮 + */ + public void setButton(AlertButton... buttons) { + clearButton(); + putButtons(buttons); + } + + /** + * 追加弹窗按钮,使用按钮默认位置 + * + * @param buttons 弹窗按钮 + */ + public void putButtons(AlertButton... buttons) { + if (buttons != null) { + for (int i = 0; i < buttons.length; i++) { + final int j = i; + buttons[i].setOnAction(e -> { + action = buttons[j].action; + if (onActionEvent == null || onActionEvent.handler(buttons[j].action)) { + close(); + } + }); + switch (buttons[i].pos) { + case LEFT -> leftButtons.getChildren().add(buttons[i]); + case CENTER -> centerButtons.getChildren().add(buttons[i]); + case RIGHT -> rightButtons.getChildren().add(buttons[i]); + } + } + } + } + + /** + * 追加弹窗按钮 + * + * @param to 目标容器 + * @param buttons 弹窗按钮 + */ + public void putButtons(HBox to, AlertButton... buttons) { + for (int i = 0; i < buttons.length; i++) { + final int j = i; + buttons[i].setOnAction(e -> { + action = buttons[j].action; + if (onActionEvent == null || onActionEvent.handler(buttons[j].action)) { + close(); + } + }); + to.getChildren().add(buttons[i]); + } + } + + /** + * 设置左侧弹窗按钮 + * + * @param btns 按钮 + */ + public void setLeftButtons(AlertButton... btns) { + leftButtons.getChildren().clear(); + putButtons(leftButtons, btns); + } + + /** + * 设置中间弹窗按钮 + * + * @param buttons 弹窗按钮 + */ + public void setCenterButtons(AlertButton... buttons) { + centerButtons.getChildren().clear(); + putButtons(centerButtons, buttons); + } + + /** + * 设置右侧弹窗按钮 + * + * @param buttons 弹出按钮 + */ + public void setRightButtons(AlertButton... buttons) { + rightButtons.getChildren().clear(); + putButtons(rightButtons, buttons); + } + + /** + * 设置弹窗类型 + * + * @param type 弹窗类型 + */ + public void setType(AlertType type) { + this.typeProperty.set(type); + } + + /** + * 获取弹窗类型 + * + * @return 弹窗类型 + */ + public AlertType getType() { + return typeProperty.get(); + } + + /** + * 获取弹窗类型监听 + * + * @return 弹窗类型监听 + */ + public ObjectProperty typeProperty() { + return typeProperty; + } + + /** + * 设置图标 + * + * @param icon 图标 + */ + public void setIcon(Image icon) { + getIcons().setAll(icon); + } + + /** + * 添加显示回调 + * + * @param callback 回调 + */ + public void addShownListener(CallbackArg callback) { + shownListeners.add(callback); + } + + /** + * 设置弹窗动作事件(用户点击带有动作的弹窗按钮) + * + * @param onActionEvent 弹窗动作事件 + */ + public void setOnActionEvent(CallbackArgReturn onActionEvent) { + this.onActionEvent = onActionEvent; + } + + /** + * 获取最近用户动作(弹窗按钮事件动作) + * + * @return 最近用户动作 + */ + public AlertButton.Action getAction() { + return action; + } + + /** + * 获取窗体尺寸是否适应场景尺寸 + * + * @return true 为窗体尺寸是否适应场景尺寸 + */ + public boolean isEnableSizeToScene() { + return enableSizeToScene; + } + + /** + * 设置窗体尺寸是否适应场景尺寸,显示前设置有效,默认 true + * + * @param enableSizeToScene true 为窗体尺寸适应场景尺寸 + */ + public void setEnableSizeToScene(boolean enableSizeToScene) { + this.enableSizeToScene = enableSizeToScene; + } + + /** + * 获取按钮布局的主面板 + * + * @return 按钮布局主面板 + */ + public BorderPane getBtnPane() { + return btnPane; + } + + /** + * 获取按钮布局面板的左侧面板(如果按钮布局主面板被修改,此面板无效) + * + * @return 按钮布局左侧面板 + */ + public HBox getLeftButtons() { + return leftButtons; + } + + /** + * 获取按钮布局面板的中间面板(如果按钮布局主面板被修改,此面板无效) + * + * @return 按钮布局中间面板 + */ + public HBox getCenterButtons() { + return centerButtons; + } + + /** + * 获取按钮布局面板的右侧面板(如果按钮布局主面板被修改,此面板无效) + * + * @return 按钮布局右侧面板 + */ + public HBox getRightButtons() { + return rightButtons; + } + + /** + * 获取根布局(BorderPane 下部分为按钮面板) + * + * @return 根布局面板 + */ + public BorderPane getRoot() { + return root; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertFile.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertFile.java new file mode 100644 index 0000000..c845316 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertFile.java @@ -0,0 +1,307 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.ui.components.FileTreeView; +import com.imyeyu.fx.ui.components.IconButton; +import com.imyeyu.fx.ui.components.ToggleIcon; +import com.imyeyu.fx.ui.components.popup.PopupTipsService; +import com.imyeyu.fx.ui.components.popup.tips.PopupTipsLabel; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.CallbackArgReturn; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeItem; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; + +import java.io.File; +import java.util.List; + +/** + * 抽象文件选择器,基本文件选择窗体 + * + * @author 夜雨 + * @since 2022-05-23 15:34 + */ +public abstract class AbstractAlertFile extends AbstractAlert implements TimiFXUI { + + + /** 当前绝对路径 */ + protected final TextField absolutePath; + + /** 切换隐藏 */ + protected final ToggleIcon toggleHide; + + /** 确认按钮 */ + protected final AlertButton confirm; + + /** 取消按钮 */ + protected final AlertButton cancel; + + /** 文件目录树 */ + protected final FileTreeView tree; + + private CallbackArgReturn, Boolean> onConfirmEvent; + + /** + * 默认构造器 + * + * @param mode 模式 + */ + public AbstractAlertFile(SelectionMode mode) { + VBox header = new VBox(); + + // 主页 + IconButton home = new IconButton(TimiFXIcon.fromName("HOME")).withBackground(); + home.getStyleClass().add(CSS.BORDER_R); + PopupTipsService.installText(home, TimiFXUI.MULTILINGUAL.text("home")); + + // 刷新 + IconButton refresh = new IconButton(TimiFXIcon.fromName("REFRESH")).withBackground(); + refresh.getStyleClass().add(CSS.BORDER_R); + PopupTipsService.installText(refresh, TimiFXUI.MULTILINGUAL.text("refresh")); + + // 创建文件夹 + IconButton mkdir = new IconButton(TimiFXIcon.fromName("FOLDER_ADD")).withBackground(); + mkdir.getStyleClass().add(CSS.BORDER_R); + PopupTipsService.installText(mkdir, TimiFXUI.MULTILINGUAL.text("file.mkdir")); + + // 删除 + IconButton destroy = new IconButton(TimiFXIcon.fromName("FAIL", Colorful.RED)).withBackground(); + destroy.getStyleClass().add(CSS.BORDER_R); + PopupTipsService.installText(destroy, TimiFXUI.MULTILINGUAL.text("delete")); + + // 切换隐藏 + toggleHide = new ToggleIcon(TimiFXIcon.fromName("HIDE")); + PopupTipsService.installText(toggleHide, TimiFXUI.MULTILINGUAL.text("file.show_hide")); + + BorderPane ctrl = new BorderPane(); + ctrl.setLeft(new HBox(home, refresh, mkdir, destroy)); + ctrl.setRight(toggleHide); + header.getChildren().add(ctrl); + + BorderPane absolutePathPane = new BorderPane(); + absolutePathPane.setBorder(Stroke.TOP); + + // 绝对路径 + absolutePath = new TextField(); + absolutePath.getStyleClass().add(CSS.BORDER_N); + IconButton absolutePathGo = new IconButton(TimiFXIcon.fromName("ARROW_2_E")).withBackground(); + absolutePathGo.getStyleClass().add(CSS.BORDER_L); + { + if (mode == SelectionMode.SINGLE) { + PopupTipsLabel tips = PopupTipsService.installBindingText(absolutePath, absolutePath.textProperty()); + tips.enableProperty().bind(absolutePath.textProperty().isNotEmpty()); + } else { + absolutePathPane.setVisible(false); + absolutePathPane.setManaged(false); + } + + absolutePathPane.setCenter(absolutePath); + absolutePathPane.setRight(absolutePathGo); + } + header.getChildren().add(absolutePathPane); + + // 目录树 + tree = new FileTreeView(); + tree.setBorder(Stroke.TOP); + tree.getSelectionModel().setSelectionMode(mode); + + root.setTop(header); + root.setCenter(tree); + + btnPane.setBorder(Stroke.TOP); + + { + confirm = AlertButton.confirm(); + confirm.getStyleClass().add(CSS.BORDER_L); + + cancel = AlertButton.cancel(); + cancel.getStyleClass().add(CSS.BORDER_L); + + setRightButtons(confirm, cancel); + btnPane.setPadding(Insets.EMPTY); + rightButtons.setSpacing(0); + } + + setEnableSizeToScene(false); + setWidth(390); + setHeight(490); + + // ---------- 事件 ---------- + + // 根目录 + home.setOnAction(e -> { + tree.getSelectionModel().clearAndSelect(0); + tree.scrollTo(0); + }); + + // 刷新 + refresh.disableProperty().bind(Bindings.createBooleanBinding(() -> { + List> items = tree.getSelectionModel().getSelectedItems(); + // 没有选择、多选、选的不是文件时禁用 + return items == null || items.size() != 1 || items.get(0).getValue().isFile(); + }, tree.getSelectionModel().selectedItemProperty())); + refresh.setOnAction(e -> tree.refreshItem(tree.getSelectionModel().getSelectedItem())); + + // 创建文件夹 + mkdir.disableProperty().bind(refresh.disableProperty()); + mkdir.setOnAction(e -> tree.mkdir(tree.getSelectionModel().getSelectedItem())); + + // 删除 + List roots = List.of(File.listRoots()); + destroy.disableProperty().bind(Bindings.createBooleanBinding(() -> { + ObservableList> items = tree.getSelectionModel().getSelectedItems(); + if (items.isEmpty()) { + return true; + } + for (int i = 0; i < items.size(); i++) { + if (roots.contains(items.get(i).getValue())) { + return true; + } + } + return false; + }, tree.getSelectionModel().getSelectedItems())); + destroy.setOnAction(e -> tree.destroy(tree.getSelectionModel().getSelectedItems())); + + // 显示隐藏 + toggleHide.selectedProperty().bindBidirectional(tree.showHideProperty()); + toggleHide.setOnAction(e -> tree.getRoots().forEach(i -> i.setExpanded(false))); + + // 前往路径 + absolutePathGo.setOnAction(e -> { + if (TimiJava.isNotEmpty(absolutePath.getText())) { + File file = new File(absolutePath.getText()); + if (file.exists()) { + tree.selectItem(file); + } else { + AlertTips.error(this, TimiFXUI.MULTILINGUAL.textArgs("file.tips.not_found_target", absolutePath.getText())); + } + } + }); + + // 选中 + tree.getSelectionModel().selectedItemProperty().addListener((obs, o, newItem) -> { + if (newItem != null && newItem.getValue() != null) { + absolutePath.setText(newItem.getValue().getAbsolutePath()); + } + }); + + // 双击触发确认 + tree.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> { + if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { + confirm.fire(); + } + }); + + // 确认 + confirm.setOnAction(e -> { + if (onConfirmEvent != null) { + List list = tree.getSelectionModel().getSelectedItems().stream().map(TreeItem::getValue).toList(); + if (onConfirmEvent.handler(list)) { + close(); + } + } else { + close(); + } + }); + } + + /** + * 添加构建节点过滤器,返回 false 时不创建该节点 + * + * @param itemFilter 节点过滤器 + */ + public void addItemFilter(CallbackArgReturn itemFilter) { + tree.addItemFilter(itemFilter); + } + + /** + * 移除构建节点过滤器 + * + * @param itemFilter 节点过滤器 + */ + public void removeItemFilter(CallbackArgReturn itemFilter) { + tree.removeItemFilter(itemFilter); + } + + /** + * 异步选择目标目录 + * + * @param path 目标目录 + */ + public void selectItem(String path) { + tree.selectItem(path); + } + + /** + * 异步选择目标文件 + * + * @param file 目标文件 + */ + public void selectItem(File file) { + tree.selectItem(file); + } + + /** + * 获取是否显示隐藏文件 + * + * @param showHide true 为显示隐藏文件 + */ + public void isShowHide(boolean showHide) { + toggleHide.setSelected(showHide); + } + + /** + * 设置是否显示隐藏文件 + * + * @param showHide true 为显示隐藏文件 + */ + public void setShowHide(boolean showHide) { + toggleHide.setSelected(showHide); + } + + /** + * 获取切换显示隐藏文件监听 + * + * @return 切换显示隐藏文件监听 + */ + public BooleanProperty showHideProperty() { + return toggleHide.selectedProperty(); + } + + /** + * 获取确认事件 + * + * @return 确认事件 + */ + public CallbackArgReturn, Boolean> getOnConfirmEvent() { + return onConfirmEvent; + } + + /** + * 设置确认事件,返回 true 自动关闭窗体 + * + * @param onConfirmEvent 确认事件 + */ + public void setOnConfirmEvent(CallbackArgReturn, Boolean> onConfirmEvent) { + this.onConfirmEvent = onConfirmEvent; + } + + /** + * 获取文件目录树 + * + * @return 文件目录树 + */ + public FileTreeView getTree() { + return tree; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertInput.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertInput.java new file mode 100644 index 0000000..2f925b0 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AbstractAlertInput.java @@ -0,0 +1,111 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.java.TimiJava; +import javafx.geometry.Insets; +import javafx.scene.control.Label; +import javafx.scene.control.TextInputControl; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Region; + +/** + * 抽象输入弹窗 + * + * @author 夜雨 + * @since 2022-04-07 10:51 + */ +public abstract class AbstractAlertInput extends AbstractAlert { + + /** 输入组件 */ + protected final T input; + + /** 提示文本 */ + protected final Label tips; + + /** 内容面板 */ + protected final BorderPane content; + + /** + * 输入弹窗 + * + * @param t 输入组件 + * @param title 标题 + */ + public AbstractAlertInput(T t, String title) { + this(t, AlertType.INFORMATION, title, "", "", AlertButton.confirm(), AlertButton.cancel()); + } + + /** + * 输入弹窗 + * + * @param t 输入组件 + * @param type 弹窗类型 + * @param title 标题 + * @param content 内容 + * @param text 预设输入框文本 + * @param btns 可控按钮 + */ + public AbstractAlertInput(T t, AlertType type, String title, String content, String text, AlertButton... btns) { + tips = new Label(content); + tips.setTextFill(GRAY); + tips.setWrapText(true); + tips.visibleProperty().bind(tips.textProperty().isNotEmpty()); + tips.managedProperty().bind(tips.visibleProperty()); + input = t; + input.setText(text); + if (input.getPrefWidth() == Region.USE_COMPUTED_SIZE) { + tips.setPrefWidth(360); + input.setPrefWidth(360); + } + + root.setCenter(this.content = new BorderPane() {{ + setMargin(tips, new Insets(4, 6, 4, 6)); + setPadding(PADDING_CONTENT); + setTop(tips); + setCenter(input); + }}); + + setResizable(false); + setType(type); + + if (TimiJava.isNotEmpty(title)) { + setTitle(title); + } + putButtons(btns); + } + + /** + * 获取输入的文本 + * + * @return 输入的文本 + */ + public String getText() { + return input.getText(); + } + + /** + * 设置提示 + * + * @param tips 提示文本 + */ + public void setTips(String tips) { + this.tips.setText(tips); + } + + /** + * 获取提示标签组件 + * + * @return 提示标签组件 + */ + public Label getTips() { + return tips; + } + + /** + * 获取输入组件 + * + * @return 输入组件 + */ + public T getInput() { + return input; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertButton.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertButton.java new file mode 100644 index 0000000..dc1eb43 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertButton.java @@ -0,0 +1,249 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.ui.TimiFXUI; +import javafx.geometry.HPos; +import javafx.scene.control.Button; + +/** + * 弹窗按钮 + * + * @author 夜雨 + * @since 2022-01-07 09:37 + */ +public class AlertButton extends Button { + + /** + * 按钮通用动作,用于标记,具体事件由调用决定 + * + * @author 夜雨 + * @since 2022-01-20 00:55 + */ + public enum Action { + + /** 同意 */ + APPLY, + + /** 好 */ + OK, + + /** 取消 */ + CANCEL, + + /** 关闭 */ + CLOSE, + + /** 确认 */ + CONFIRM, + + /** 是 */ + YES, + + /** 否 */ + NO, + + /** 完成 */ + FINISH, + + /** 上一步 */ + PREVIOUS, + + /** 下一步 */ + NEXT, + + /** 跳过 */ + SKIP, + + /** 保存 */ + SAVE, + + /** 用于自定义事件 */ + OTHER + } + + HPos pos; + Action action; + + /** + * 弹窗按钮构造器 + * + * @param text 文本 + */ + public AlertButton(String text) { + this(HPos.CENTER, Action.OK, text); + } + + /** + * 弹窗按钮构造器 + * + * @param pos 位置 + * @param action 动作 + * @param text 文本 + */ + public AlertButton(HPos pos, Action action, String text) { + super(text); + this.pos = pos; + this.action = action; + } + + /** + * 获取按钮所属位置 + * + * @return 按钮所属位置 + */ + public HPos getPos() { + return pos; + } + + /** + * 设置按钮所属位置 + * + * @param pos 按钮所属位置 + */ + public void setPos(HPos pos) { + this.pos = pos; + } + + /** + * 获取按钮动作 + * + * @return 按钮动作 + */ + public Action getAction() { + return action; + } + + /** + * 设置按钮动作 + * + * @param action 按钮动作 + */ + public void setAction(Action action) { + this.action = action; + } + + /** + * 按钮动作是否一致 + * + * @param o 比较对象 + * @return true 为按钮动作 AlertButton.Action 相同 + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AlertButton that = (AlertButton) o; + return action == that.action; + } + + /** + * 快速构造应用按钮 + * + * @return 应用按钮 + */ + public static AlertButton apply() { + return new AlertButton(HPos.RIGHT, Action.APPLY, TimiFXUI.MULTILINGUAL.text("alert.apply", "应用")); + } + + /** + * 快速构造明确按钮 + * + * @return 明确按钮 + */ + public static AlertButton ok() { + return new AlertButton(HPos.RIGHT, Action.OK, TimiFXUI.MULTILINGUAL.text("alert.ok", "好")); + } + + /** + * 快速构造取消按钮 + * + * @return 取消按钮 + */ + public static AlertButton cancel() { + return new AlertButton(HPos.RIGHT, Action.CANCEL, TimiFXUI.MULTILINGUAL.text("alert.cancel", "取消")); + } + + /** + * 快速构造关闭按钮 + * + * @return 关闭按钮 + */ + public static AlertButton close() { + return new AlertButton(HPos.RIGHT, Action.CLOSE, TimiFXUI.MULTILINGUAL.text("alert.close", "关闭")); + } + + /** + * 快速构造确认按钮 + * + * @return 确认按钮 + */ + public static AlertButton confirm() { + return new AlertButton(HPos.RIGHT, Action.CONFIRM, TimiFXUI.MULTILINGUAL.text("alert.confirm", "确认")); + } + + /** + * 快速构造确定按钮 + * + * @return 确定按钮 + */ + public static AlertButton yes() { + return new AlertButton(HPos.RIGHT, Action.YES, TimiFXUI.MULTILINGUAL.text("alert.yes", "是")); + } + + /** + * 快速构造否定按钮 + * + * @return 否定按钮 + */ + public static AlertButton no() { + return new AlertButton(HPos.RIGHT, Action.NO, TimiFXUI.MULTILINGUAL.text("alert.no", "否")); + } + + /** + * 快速构造完成按钮 + * + * @return 完成按钮 + */ + public static AlertButton finish() { + return new AlertButton(HPos.RIGHT, Action.FINISH, TimiFXUI.MULTILINGUAL.text("alert.finish", "完成")); + } + + /** + * 快速构造上一步按钮 + * + * @return 上一步按钮 + */ + public static AlertButton previous() { + return new AlertButton(HPos.RIGHT, Action.PREVIOUS, TimiFXUI.MULTILINGUAL.text("alert.previous", "上一步")); + } + + /** + * 快速构造下一步按钮 + * + * @return 下一步按钮 + */ + public static AlertButton next() { + return new AlertButton(HPos.RIGHT, Action.NEXT, TimiFXUI.MULTILINGUAL.text("alert.next", "下一步")); + } + + /** + * 快速构造跳过按钮 + * + * @return 跳过按钮 + */ + public static AlertButton skip() { + return new AlertButton(HPos.RIGHT, Action.SKIP, TimiFXUI.MULTILINGUAL.text("alert.skip", "跳过")); + } + + /** + * 快速构造保存按钮 + * + * @return 保存按钮 + */ + public static AlertButton save() { + return new AlertButton(HPos.RIGHT, Action.SAVE, TimiFXUI.MULTILINGUAL.text("alert.save", "保存")); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertConfirm.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertConfirm.java new file mode 100644 index 0000000..b0b3af0 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertConfirm.java @@ -0,0 +1,67 @@ +package com.imyeyu.fx.ui.components.alert; + +/** + * 询问确认弹窗 + * + * @author 夜雨 + * @since 2022-08-20 23:37 + */ +public abstract class AlertConfirm extends AlertTips { + + /** + * 默认构造器 + * + * @param content 询问内容 + */ + public AlertConfirm(String content) { + this(AlertType.INFORMATION, content); + } + + /** + * 构造器 + * + * @param type 类型 + */ + public AlertConfirm(AlertType type) { + this(type, ""); + } + + /** + * 构造器 + * + * @param type 类型 + * @param content 询问内容 + */ + public AlertConfirm(AlertType type, String content) { + this(type, content, AlertButton.yes(), AlertButton.no()); + } + + /** + * 默认构造 + * + * @param type 类型 + * @param content 提示内容 + * @param btns 按钮 + */ + public AlertConfirm(AlertType type, String content, AlertButton... btns) { + super(type, btns); + + setTips(content); + setOnActionEvent(action -> { + if (action == AlertButton.Action.YES || action == AlertButton.Action.CONFIRM || action == AlertButton.Action.OK) { + onConfirm(); + } else { + onCancel(); + } + return true; + }); + } + + /** 确认事件 */ + protected abstract void onConfirm(); + + /** 取消事件 */ + protected void onCancel() { + // 子类可选实现 + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileBlendSelector.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileBlendSelector.java new file mode 100644 index 0000000..2b06eb2 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileBlendSelector.java @@ -0,0 +1,30 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import javafx.beans.binding.Bindings; +import javafx.scene.control.SelectionMode; + +/** + * 混合文件选择,可以选择文件也可以选择目录 + * + * @author 夜雨 + * @since 2023-01-24 19:07 + */ +public class AlertFileBlendSelector extends AbstractAlertFile { + + /** + * 构造器 + * + * @param mode 选择模式 + */ + public AlertFileBlendSelector(SelectionMode mode) { + super(mode); + + // 确认 + confirm.disableProperty().bind(Bindings.isEmpty(tree.getSelectionModel().getSelectedItems())); + + getIcons().setAll(TimiFXIcon.iconFromName("FOLDER")); + setTitle(TimiFXUI.MULTILINGUAL.text("file.tips.select_directory")); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFilePathSelector.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFilePathSelector.java new file mode 100644 index 0000000..050e56d --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFilePathSelector.java @@ -0,0 +1,38 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import javafx.beans.binding.Bindings; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TreeItem; + +import java.io.File; + +/** + * 目录选择 + * + * @author 夜雨 + * @since 2022-05-20 10:55 + */ +public class AlertFilePathSelector extends AbstractAlertFile { + + /** + * 构造器 + * + * @param mode 选择模式 + */ + public AlertFilePathSelector(SelectionMode mode) { + super(mode); + + // 确认 + confirm.disableProperty().bind(Bindings.createBooleanBinding(() -> { + TreeItem item = tree.getSelectionModel().getSelectedItem(); + return item == null || item.getValue().isFile(); + }, tree.getSelectionModel().selectedItemProperty())); + + getIcons().setAll(TimiFXIcon.iconFromName("FOLDER")); + setTitle(TimiFXUI.MULTILINGUAL.text("file.tips.select_directory")); + + addItemFilter(File::isDirectory); + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileSelector.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileSelector.java new file mode 100644 index 0000000..493435c --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertFileSelector.java @@ -0,0 +1,87 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.io.IO; +import com.imyeyu.java.TimiJava; +import com.imyeyu.utils.Text; +import javafx.beans.binding.Bindings; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TreeItem; + +import java.io.File; + +/** + * 文件选择 + * + * @author 夜雨 + * @since 2022-05-23 15:49 + */ +public class AlertFileSelector extends AbstractAlertFile { + + /** 格式过滤列表 */ + protected final ObservableList formatFilters; + + private String[] formatFiltersCache; + + /** + * 默认构造 + * + * @param mode 选择模式 + */ + public AlertFileSelector(SelectionMode mode) { + super(mode); + formatFilters = FXCollections.observableArrayList(); + + // 确认 + confirm.disableProperty().bind(Bindings.createBooleanBinding(() -> { + TreeItem item = tree.getSelectionModel().getSelectedItem(); + return item == null || item.getValue().isDirectory(); + }, tree.getSelectionModel().selectedItemProperty())); + + getIcons().setAll(TimiFXIcon.iconFromName("FILE")); + setTitle(TimiFXUI.MULTILINGUAL.text("file.select")); + + addItemFilter(file -> { + if (file.isDirectory() || TimiJava.isEmpty(formatFiltersCache)) { + return true; + } + return Text.eqIgnoreCaseOr(IO.fileExtension(file), formatFiltersCache); + }); + formatFilters.addListener((ListChangeListener) c -> { + while (c.next()) { + formatFiltersCache = formatFilters.toArray(new String[0]); + } + }); + } + + /** + * 添加文件格式过滤,默认显示所有格式的文件,添加过滤后将只显示过滤格式列表的文件 + * + * @param formats 需要显示的文件格式 + */ + public void addFormatFilters(String... formats) { + formatFilters.addAll(formats); + } + + /** + * 移除文件格式过滤 + * + * @param formats 不需显示的文件格式 + */ + public void removeFormatFilters(String... formats) { + formatFilters.removeAll(formats); + } + + /** + * 获取格式过滤列表 + * + * @return 格式过滤列表 + */ + public ObservableList getFormatFilters() { + return formatFilters; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertLoading.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertLoading.java new file mode 100644 index 0000000..0385674 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertLoading.java @@ -0,0 +1,90 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.ui.TimiFXUI; +import javafx.beans.property.StringProperty; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.StackPane; +import javafx.scene.text.TextAlignment; +import javafx.stage.StageStyle; + +/** + * 阻塞式弹出加载中弹窗,此弹窗用户必须等待,不能手动关闭,显示期间不可操作其他窗体 + * + * @author 夜雨 + * @since 2022-01-07 16:39 + */ +public class AlertLoading extends AbstractAlert { + + /** 提示标签 */ + protected Label tips; + + /** 默认构造 */ + public AlertLoading() { + this(TimiFXUI.MULTILINGUAL.text("loading")); + } + + /** + * 构造器 + * + * @param tips 提示 + */ + public AlertLoading(String tips) { + this.tips = new Label(tips); + this.tips.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + this.tips.setWrapText(true); + this.tips.setPrefWidth(280); + this.tips.setAlignment(Pos.CENTER); + this.tips.setTextAlignment(TextAlignment.CENTER); + + BorderPane.setMargin(this.tips, PADDING_CONTENT); + root.setEffect(Shadow.POPUP); + root.setBorder(Stroke.DEFAULT); + root.setCenter(this.tips); + root.setBackground(BG.DEFAULT); + root.setBottom(null); + + StackPane shadow = new StackPane(); + shadow.setPadding(Shadow.PADDING); + shadow.setBackground(BG.TRANSPARENT); + shadow.getChildren().add(root); + + getScene().setFill(null); + getScene().setRoot(shadow); + setTitle(TimiFXUI.MULTILINGUAL.text("loading")); + initStyle(StageStyle.TRANSPARENT); + } + + @Override + protected void onEscape() { + // 禁用 ESC 关闭 + } + + /** + * 设置提示文本 + * + * @param tips 提示文本 + */ + public void setTips(String tips) { + this.tips.setText(tips); + } + + /** + * 获取提示文本属性 + * + * @return 提示文本属性 + */ + public StringProperty tipsProperty() { + return tips.textProperty(); + } + + /** + * 获取当前提示文本 + * + * @return 提示文本 + */ + public String getTips() { + return tips.getText(); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertPassword.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertPassword.java new file mode 100644 index 0000000..eb3d897 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertPassword.java @@ -0,0 +1,44 @@ +package com.imyeyu.fx.ui.components.alert; + +import javafx.scene.control.PasswordField; + +/** + * 密码输入弹窗 + * + * @author 夜雨 + * @since 2022-04-06 16:47 + */ +public class AlertPassword extends AbstractAlertInput { + + /** + * 密码输入弹窗 + * + * @param content 内容 + */ + public AlertPassword(String content) { + this(AlertType.INFORMATION, AlertType.INFORMATION.getTitle(), content, "", AlertButton.confirm(), AlertButton.cancel()); + } + + /** + * 密码输入弹窗 + * + * @param content 内容 + * @param btns 可控按钮 + */ + public AlertPassword(String content, AlertButton... btns) { + this(AlertType.INFORMATION, AlertType.INFORMATION.getTitle(), content, "", btns); + } + + /** + * 密码输入弹窗 + * + * @param type 弹窗类型 + * @param title 标题 + * @param content 内容 + * @param text 预设输入框文本 + * @param btns 可控按钮 + */ + public AlertPassword(AlertType type, String title, String content, String text, AlertButton... btns) { + super(new PasswordField(), type, title, content, text, btns); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/alert/AlertTextArea.java b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertTextArea.java new file mode 100644 index 0000000..069b8f0 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/alert/AlertTextArea.java @@ -0,0 +1,215 @@ +package com.imyeyu.fx.ui.components.alert; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.utils.SmoothScroll; +import com.imyeyu.java.bean.CallbackArg; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.scene.control.TextArea; +import javafx.scene.layout.Border; +import javafx.stage.Window; + +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * 文本域输入弹窗 + * + * @author 夜雨 + * @since 2022-01-20 00:36 + */ +public class AlertTextArea extends AbstractAlertInput