scheduled = new RunAsyncScheduled<>() {
+
+ @Override
+ protected void onScheduled() {
+ scheduleEvent.handler(this);
+ }
+
+ @Override
+ protected Void call() {
+ return null;
+ }
+
+ @Override
+ protected void onFinish() {
+ finishCallback.handler();
+ }
+ };
+ scheduled.start();
+ return scheduled;
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/Anchor.java b/src/main/java/com/imyeyu/fx/utils/Anchor.java
new file mode 100644
index 0000000..c0b7093
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/Anchor.java
@@ -0,0 +1,164 @@
+package com.imyeyu.fx.utils;
+
+import javafx.scene.Node;
+import javafx.scene.layout.AnchorPane;
+
+/**
+ * 锚点布局简化操作
+ *
+ *
+ * Anchor.def(node, top, right, bottom, left);
+ * Anchor.def(node, topRightBottomLeft);
+ * Anchor.def(node, topBottom, leftRight);
+ *
+ *
+ * @author 夜雨
+ * @since 2021-05-16 23:31
+ */
+public class Anchor extends AnchorPane {
+
+ /**
+ * {@link #leftTop(Node, Number, Number)} 的语义化方法
+ *
+ * @param node 组件
+ * @param x 距离左边
+ * @param y 距离上边
+ */
+ public static void xy(Node node, Number x, Number y) {
+ def(node, y, null, null, x);
+ }
+
+ /**
+ * 根据左上角定位
+ *
+ * @param node 组件
+ * @param left 距离左边
+ * @param top 距离上边
+ */
+ public static void leftTop(Node node, Number left, Number top) {
+ def(node, top, null, null, left);
+ }
+
+ /**
+ * 根据右上角定位
+ *
+ * @param node 组件
+ * @param right 距离右边
+ * @param top 距离上边
+ */
+ public static void rightTop(Node node, Number right, Number top) {
+ def(node, top, right, null, null);
+ }
+
+ /**
+ * 根据左下角定位
+ *
+ * @param node 组件
+ * @param left 距离左边
+ * @param bottom 距离下边
+ */
+ public static void leftBottom(Node node, Number left, Number bottom) {
+ def(node, null, null, bottom, left);
+ }
+
+ /**
+ * 根据右下角定位
+ *
+ * @param node 组件
+ * @param right 距离右边
+ * @param bottom 距离下边
+ */
+ public static void rightBottom(Node node, Number right, Number bottom) {
+ def(node, null, right, bottom, null);
+ }
+
+ /**
+ * 除了上边,其他贴紧父级组件
+ *
+ * @param node 组件
+ */
+ public static void exTop(Node node) {
+ def(node, null, 0, 0, 0);
+ }
+
+ /**
+ * 除了左边,其他贴紧父级组件
+ *
+ * @param node 组件
+ */
+ public static void exLeft(Node node) {
+ def(node, 0, 0, 0, null);
+ }
+
+ /**
+ * 除了右边,其他贴紧父级组件
+ *
+ * @param node 组件
+ */
+ public static void exRight(Node node) {
+ def(node, 0, null, 0, 0);
+ }
+
+ /**
+ * 除了下边,其他贴紧父级组件
+ *
+ * @param node 组件
+ */
+ public static void exBottom(Node node) {
+ def(node, 0, 0, null, 0);
+ }
+
+ /**
+ * 四边完全贴紧父级组件
+ *
+ * @param node 组件
+ */
+ public static void def(Node node) {
+ def(node, 0, 0, 0, 0);
+ }
+
+ /**
+ * 设置 AnchorPane 四边边距
+ *
+ * @param node 组件
+ * @param size 大小
+ */
+ public static void def(Node node, Number size) {
+ def(node, size, size, size, size);
+ }
+
+ /**
+ * 设置 AnchorPane 上下和左右边距
+ *
+ * @param node 组件
+ * @param topBottom 上下
+ * @param leftRight 左右
+ */
+ public static void def(Node node, Number topBottom, Number leftRight) {
+ def(node, topBottom, leftRight, topBottom, leftRight);
+ }
+
+ /**
+ * 设置 AnchorPane 四边间距,传值 null 为不设定
+ *
+ * @param node 组件
+ * @param top 上
+ * @param right 右
+ * @param bottom 下
+ * @param left 左
+ */
+ public static void def(Node node, Number top, Number right, Number bottom, Number left) {
+ if (top != null) {
+ setTopAnchor(node, top.doubleValue());
+ }
+ if (left != null) {
+ setLeftAnchor(node, left.doubleValue());
+ }
+ if (right != null) {
+ setRightAnchor(node, right.doubleValue());
+ }
+ if (bottom != null) {
+ setBottomAnchor(node, bottom.doubleValue());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/imyeyu/fx/utils/AnimationRenderer.java b/src/main/java/com/imyeyu/fx/utils/AnimationRenderer.java
new file mode 100644
index 0000000..b355d65
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/AnimationRenderer.java
@@ -0,0 +1,356 @@
+package com.imyeyu.fx.utils;
+
+import javafx.animation.AnimationTimer;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.ReadOnlyDoubleProperty;
+import javafx.beans.property.ReadOnlyIntegerProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.util.Duration;
+import com.imyeyu.fx.task.RunAsyncScheduled;
+import com.imyeyu.java.bean.Callback;
+import com.imyeyu.java.bean.CallbackArg;
+
+import java.util.Iterator;
+import java.util.LinkedList;
+
+/**
+ * 动画帧渲染器,可控帧率,在 JVM 启动参数含-Djavafx.animation.fullspeed=true时,预设 FPS
+ * 才可以突破屏幕刷新率。
+ *
+ *
+ * Box box = new Box(128, 128, 128);
+ * box.setDrawMode(DrawMode.LINE);
+ * box.setCullFace(CullFace.BACK);
+ * box.setMaterial(new PhongMaterial(RED));
+ * box.setRotationAxis(new Point3D(0, 64, 0));
+ *
+ * // 以 240 FPS 每秒 90 度旋转一个 3D 立方体
+ * AnimationRenderer renderer = new AnimationRenderer(240);
+ * renderer.addRenderCallback(deltaSecond -> {
+ * box.setRotate(box.getRotate() + 90 * deltaSecond);
+ * });
+ *
+ *
+ * @author 夜雨
+ * @since 2023-03-08 09:48
+ */
+public class AnimationRenderer {
+
+ /** 渲染回调 */
+ protected final LinkedList> renderCallbacks;
+
+ /** 动画渲染队列 */
+ protected final LinkedList animations;
+
+ /** 渲染器 */
+ protected final AnimationTimer timer;
+
+ /** 平均帧生成时间 */
+ protected final DoubleProperty mpf;
+
+ /** 平均帧率 */
+ protected final IntegerProperty fps;
+
+ /** 状态计时器 */
+ protected final RunAsyncScheduled> statusTimer;
+
+ /** 当前帧 */
+ private double nowNanos;
+
+ /** 上一帧 */
+ private double lastNanos;
+
+ /** 累计帧差(纳秒) */
+ private double deltaNanos;
+
+ /** 累计帧差(秒) */
+ private double deltaSecond;
+
+ /** 当前帧差 */
+ private double betweenNanos;
+
+ /** 标准帧生成时间(纳秒) */
+ private double NPF;
+
+ /** 预设渲染帧率 */
+ private int prefFPS;
+
+ /** 默认构造,60 FPS */
+ public AnimationRenderer() {
+ this(60);
+ }
+
+ /**
+ * 标准构造
+ *
+ * @param prefFPS 预设帧率
+ */
+ public AnimationRenderer(int prefFPS) {
+ setPrefFPS(prefFPS);
+
+ fps = new SimpleIntegerProperty(0);
+ mpf = new SimpleDoubleProperty(0);
+ renderCallbacks = new LinkedList<>();
+ animations = new LinkedList<>();
+
+ timer = new AnimationTimer() {
+
+ @Override
+ public void handle(long now) {
+ nowNanos = now;
+ if (0 < lastNanos) {
+ // 计算帧差
+ deltaNanos += betweenNanos = nowNanos - lastNanos;
+
+ // 累计帧差大于最小帧生成时间(足够渲染下一帧)
+ if (NPF <= deltaNanos) {
+ deltaSecond = deltaNanos * 1E-9;
+
+ synchronized (renderCallbacks) {
+ for (CallbackArg renderCallback : renderCallbacks) {
+ renderCallback.handler(deltaSecond);
+ }
+ }
+ synchronized (animations) {
+ // 动画
+ Iterator iterator = animations.iterator();
+ while (iterator.hasNext()) {
+ Animation next = iterator.next();
+ if (next.diedAt < millis()) {
+ next.callback.handler(deltaSecond, 1);
+ if (next.onFinishedEvent != null) {
+ next.onFinishedEvent.handler();
+ }
+ iterator.remove();
+ } else {
+ next.callback.handler(deltaSecond, (millis() - next.startAt) / next.ttl);
+ }
+ }
+ }
+
+ // 消耗剩余帧差
+ deltaNanos = deltaNanos % NPF;
+ }
+ }
+ lastNanos = nowNanos;
+ }
+ };
+
+ // 状态计算
+ statusTimer = RunAsyncScheduled.finish(Duration.seconds(1), new Callback() {
+
+ long total = 0, old;
+
+ {
+ renderCallbacks.add(nowNanos -> total++);
+ }
+
+ @Override
+ public void handler() {
+ // 帧生成时间
+ mpf.set(betweenNanos * 1E-6);
+
+ // 帧率
+ fps.set((int) (total - old));
+ old = total;
+ }
+ });
+ }
+
+ /** 启动 */
+ public void start() {
+ nowNanos = lastNanos = 0;
+ deltaNanos = NPF;
+ timer.start();
+ }
+
+ /** 停止 */
+ public void stop() {
+ timer.stop();
+ }
+
+ /**
+ * 添加渲染动画
+ *
+ * @param duration 持续时间
+ * @param callback 动画回调
+ */
+ public void render(Duration duration, AnimationCallback callback) {
+ render(duration, callback, null);
+ }
+
+ /**
+ * 添加渲染动画
+ *
+ * @param duration 持续时间
+ * @param callback 动画回调
+ * @param onFinishedEvent 动画完成回调
+ */
+ public void render(Duration duration, AnimationCallback callback, Callback onFinishedEvent) {
+ synchronized (animations) {
+ Animation animation = new Animation();
+ animation.startAt = nowNanos * 1E-6;
+ animation.diedAt = animation.startAt + duration.toMillis();
+ animation.ttl = duration.toMillis();
+ animation.callback = callback;
+ animation.onFinishedEvent = onFinishedEvent;
+ animations.add(animation);
+ }
+ }
+
+ /**
+ * 添加渲染回调
+ *
+ * @param callback 回调
+ */
+ public void addRenderCallback(CallbackArg callback) {
+ synchronized (renderCallbacks) {
+ renderCallbacks.add(callback);
+ }
+ }
+
+ /**
+ * 移除渲染回调
+ *
+ * @param callback 回调
+ */
+ public void removeRenderCallback(CallbackArg callback) {
+ synchronized (renderCallbacks) {
+ renderCallbacks.remove(callback);
+ }
+ }
+
+ /**
+ * 获取预设 FPS
+ *
+ * @return 预设 FPS
+ */
+ public int getPrefFPS() {
+ return prefFPS;
+ }
+
+ /**
+ * 预设 FPS,渲染器会尽量匹配此帧率渲染,可能会突破少许,系统资源紧张时实际渲染帧率会低于预设
+ *
+ * @param prefFPS FPS 取值范围 [1, 1000]
+ */
+ public void setPrefFPS(int prefFPS) {
+ if (prefFPS < 1) {
+ throw new IllegalArgumentException("pref fps can not less then 1");
+ }
+ this.prefFPS = prefFPS;
+ this.NPF = 1E9 / prefFPS;
+ }
+
+ /**
+ * 获取帧生成时间(毫秒)
+ *
+ * @return 帧生成时间
+ */
+ public double getMPF() {
+ return mpf.get();
+ }
+
+ /**
+ * 获取帧生成时间属性(毫秒)
+ *
+ * @return 帧生成时间属性(毫秒)
+ */
+ public ReadOnlyDoubleProperty mpfProperty() {
+ return mpf;
+ }
+
+ /**
+ * 获取当前渲染 FPS
+ *
+ * @return FPS
+ */
+ public int getFPS() {
+ return fps.get();
+ }
+
+ /**
+ * 获取当前渲染 FPS 属性
+ *
+ * @return FPS 属性
+ */
+ public ReadOnlyIntegerProperty fpsProperty() {
+ return fps;
+ }
+
+ /**
+ * 获取累计帧差(纳秒)
+ *
+ * @return 累计帧差(纳秒)
+ */
+ public double deltaNanos() {
+ return deltaNanos;
+ }
+
+ /**
+ * 获取累计帧差(毫秒)
+ *
+ * @return 累计帧差(毫秒)
+ */
+ public double deltaMillis() {
+ return deltaNanos * 1E-6;
+ }
+
+ /**
+ * 获取累计帧差(秒)
+ *
+ * @return 累计帧差(秒)
+ */
+ public double deltaSecond() {
+ return deltaNanos * 1E-9;
+ }
+
+ /** @return 当前帧(纳秒) */
+ public double nanos() {
+ return nowNanos;
+ }
+
+ /** @return 当前帧(毫秒) */
+ public double millis() {
+ return nowNanos * 1E-6;
+ }
+
+ /** @return 当前帧(秒) */
+ public double second() {
+ return nowNanos * 1E-9;
+ }
+
+ /**
+ * 一次性动画
+ *
+ * @author 夜雨
+ * @since 2023-05-16 16:02
+ */
+ public static class Animation {
+
+ double diedAt;
+ double startAt;
+ double ttl;
+ AnimationCallback callback;
+ Callback onFinishedEvent;
+ }
+
+ /**
+ * 一次性动画回调
+ *
+ * @author 夜雨
+ * @since 2023-05-14 10:10
+ */
+ public interface AnimationCallback {
+
+ /**
+ * 处理器
+ *
+ * @param deltaSecond 帧差
+ * @param percent 动画进度百分比,取值范围 [0, 1]
+ */
+ void handler(double deltaSecond, double percent);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/imyeyu/fx/utils/BgFill.java b/src/main/java/com/imyeyu/fx/utils/BgFill.java
new file mode 100644
index 0000000..c582537
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/BgFill.java
@@ -0,0 +1,198 @@
+package com.imyeyu.fx.utils;
+
+import javafx.geometry.Insets;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.paint.Color;
+import javafx.scene.paint.CycleMethod;
+import javafx.scene.paint.LinearGradient;
+import javafx.scene.paint.Paint;
+import javafx.scene.paint.Stop;
+
+import java.util.Random;
+
+/**
+ * 快速构建简单背景填充,支持渐变背景。{@link #build()} 作为导出作用,最后执行
+ *
+ *
+ * new BgFill("red").build() // 创建简单的背景填充
+ * new BgFill("red", "blue").toRight().build() // 创建由红到蓝,从左往右的渐变填充背景
+ * new BgFill("red").radii(bgRadii).insets(bgInsets).build() // 设置圆角率和边距
+ *
+ *
+ * @author 夜雨
+ * @since 2021-02-13 12:52
+ */
+public class BgFill {
+
+ private Paint color;
+ private Stop[] stops;
+ private Paint linearGradient;
+
+ /** 自定义圆角和内边距 */
+ private Insets insets;
+ private CornerRadii radii;
+
+ /**
+ * 纯色构造器
+ *
+ * @param color 颜色
+ */
+ public BgFill(Paint color) {
+ this.color = color;
+ }
+
+ /**
+ * 纯色构造器
+ *
+ * @param color 颜色
+ */
+ public BgFill(String color) {
+ this.color = Paint.valueOf(color);
+ }
+
+ /**
+ * 渐变构造器
+ *
+ * @param start 起点颜色
+ * @param end 结束颜色
+ */
+ public BgFill(String start, String end) {
+ stops = new Stop[]{new Stop(0, Color.valueOf(start)), new Stop(1, Color.valueOf(end))};
+ }
+
+ /**
+ * 设置四角圆角大小
+ *
+ * @param size 大小
+ * @return 本实例
+ */
+ public BgFill raddi(double size) {
+ this.radii = new CornerRadii(size);
+ return this;
+ }
+
+ /**
+ * 设置四角圆角大小
+ *
+ * @param topLeft 左上
+ * @param topRight 右上
+ * @param bottomRight 右下
+ * @param bottomLeft 左下
+ * @param asPercent true 为百分比数值
+ * @return 本实例
+ */
+ public BgFill raddi(double topLeft, double topRight, double bottomRight, double bottomLeft, boolean asPercent) {
+ this.radii = new CornerRadii(topLeft, topRight, bottomRight, bottomLeft, asPercent);
+ return this;
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param insets 内边距
+ * @return 本实例
+ */
+ public BgFill insets(Insets insets) {
+ this.insets = insets;
+ return this;
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param size 四边边距
+ * @return 本实例
+ */
+ public BgFill insets(double size) {
+ return insets(size, size);
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param topBottom 上下边距
+ * @param leftRight 左右边距
+ * @return 本实例
+ */
+ public BgFill insets(double topBottom, double leftRight) {
+ this.insets = new Insets(topBottom, leftRight, topBottom, leftRight);
+ return this;
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param top 上边距
+ * @param right 右边距
+ * @param bottom 下边距
+ * @param left 左边距
+ * @return 本实例
+ */
+ public BgFill insets(double top, double right, double bottom, double left) {
+ this.insets = new Insets(top, right, bottom, left);
+ return this;
+ }
+
+ /**
+ * 从左往右渐变
+ *
+ * @return 本实例
+ */
+ public BgFill toRight() {
+ if (stops == null) {
+ throw new NullPointerException("please use BgFill(start, end) constructor ");
+ }
+ linearGradient = new LinearGradient(0, 0, 1, 0, true, CycleMethod.NO_CYCLE, stops);
+ return this;
+ }
+
+ /**
+ * 从上往下渐变
+ *
+ * @return 本实例
+ */
+ public BgFill toBottom() {
+ if (stops == null) {
+ throw new NullPointerException("please use BgFill(start, end) constructor ");
+ }
+ linearGradient = new LinearGradient(0, 0, 0, 1, true, CycleMethod.NO_CYCLE, stops);
+ return this;
+ }
+
+ /**
+ * 导出背景
+ *
+ * @return 背景
+ */
+ public Background build() {
+ return new Background(buildFill(linearGradient == null ? color : linearGradient));
+ }
+
+ /**
+ * 导出背景填充
+ *
+ * @param color 颜色
+ * @return 背景填充
+ */
+ public BackgroundFill buildFill(Paint color) {
+ radii = radii == null ? CornerRadii.EMPTY : radii;
+ insets = insets == null ? Insets.EMPTY : insets;
+ return new BackgroundFill(color, radii, insets);
+ }
+
+ /**
+ * 快速构造测试背景
+ *
+ * @return 背景
+ */
+ public static Background test() {
+ Random r = new Random();
+ StringBuilder sb = new StringBuilder("#");
+ for (int i = 0; i < 6; i++) {
+ sb.append(r.nextInt(8));
+ }
+ return new BgFill(sb.toString()).build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/imyeyu/fx/utils/BgImage.java b/src/main/java/com/imyeyu/fx/utils/BgImage.java
new file mode 100644
index 0000000..6cb3ec7
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/BgImage.java
@@ -0,0 +1,288 @@
+package com.imyeyu.fx.utils;
+
+import javafx.geometry.Side;
+import javafx.scene.Node;
+import javafx.scene.image.Image;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundImage;
+import javafx.scene.layout.BackgroundPosition;
+import javafx.scene.layout.BackgroundRepeat;
+import javafx.scene.layout.BackgroundSize;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+
+/**
+ * 背景图片封装。{@link #build()} 作为导出作用,最后执行
+ *
+ * 默认原图大小,不平铺,居原点
+ *
+ *
+ * // 适应缩放的 Cover 背景图片构建
+ * new BgImage("/cover.png").cover().build()
+ *
+ *
+ * @author 夜雨
+ * @since 2021-02-13 12:53
+ */
+public class BgImage {
+
+ private BackgroundRepeat repeatX, repeatY;
+
+ private Side sideH, sideV;
+ private double posH, posV, sizeW, sizeH;
+ private boolean posHAsPercent, posVAsPercent, sizeWAsPercent, sizeHAsPercent, isContain, isCover;
+
+ private final Image img;
+
+ /**
+ * 背景图构造器
+ *
+ * @param url 图片位置(程序内资源)
+ */
+ public BgImage(String url) {
+ this(new Image(url));
+ }
+
+ /**
+ * 背景图构造器
+ *
+ * @param file 图片文件(程序外资源)
+ * @throws FileNotFoundException 找不到文件
+ */
+ public BgImage(File file) throws FileNotFoundException {
+ this(new Image(new FileInputStream(file)));
+ }
+
+ /**
+ * 背景图构造器
+ *
+ * @param img 图像
+ */
+ public BgImage(Image img) {
+ this.img = img;
+ // 初始化数据
+ repeatX = repeatY = BackgroundRepeat.REPEAT;
+ sideH = Side.LEFT;
+ sideV = Side.TOP;
+ posH = posV = 0;
+ sizeW = sizeH = -1;
+ posHAsPercent = posVAsPercent = sizeWAsPercent = sizeHAsPercent = isContain = isCover = false;
+ }
+
+ /**
+ * 双轴平铺方式
+ *
+ * @param repeat 填充方式
+ * @return 本实例
+ */
+ public BgImage repeat(BackgroundRepeat repeat) {
+ repeatX = repeatY = repeat;
+ return this;
+ }
+
+ /**
+ * X 轴平铺方式
+ *
+ * @param repeat 填充方式
+ * @return 本实例
+ */
+ public BgImage repeatX(BackgroundRepeat repeat) {
+ repeatX = repeat;
+ return this;
+ }
+
+ /**
+ * Y 轴平铺方式
+ *
+ * @param repeat 填充方式
+ * @return 本实例
+ */
+ public BgImage repeatY(BackgroundRepeat repeat) {
+ repeatY = repeat;
+ return this;
+ }
+
+ /**
+ * 背景位置
+ *
+ * @param sideH 相对水平位置,左或右
+ * @param posH 相对水平距离
+ * @param hAsPercent true 为水平距离是百分比
+ * @param sideV 相对垂直位置,上或下
+ * @param posV 相对垂直距离
+ * @param vAsPercent true 为垂直距离是百分比
+ * @return 本实例
+ */
+ public BgImage pos(Side sideH, double posH, boolean hAsPercent, Side sideV, double posV, boolean vAsPercent) {
+ this.sideH = sideH;
+ this.sideV = sideV;
+ this.posH = posH;
+ this.posV = posV;
+ this.posHAsPercent = hAsPercent;
+ this.posVAsPercent = vAsPercent;
+ return this;
+ }
+
+ /**
+ * 水平对齐方式
+ *
+ * @param side 相对位置
+ * @param size 相对距离
+ * @param asPercent true 百分比数据
+ * @return 本实例
+ */
+ public BgImage horizontal(Side side, double size, boolean asPercent) {
+ sideH = side;
+ posH = size;
+ posHAsPercent = asPercent;
+ return this;
+ }
+
+ /**
+ * 居左
+ *
+ * @param size 距离
+ * @return 本实例
+ */
+ public BgImage left(double size) {
+ return horizontal(Side.LEFT, size, false);
+ }
+
+ /**
+ * 居右
+ *
+ * @param size 距离
+ * @return 本实例
+ */
+ public BgImage right(double size) {
+ return horizontal(Side.RIGHT, size, false);
+ }
+
+ /**
+ * 垂直对齐方式
+ *
+ * @param side 相对位置
+ * @param size 相对距离
+ * @param asPercent true 百分比数据
+ * @return 本实例
+ */
+ public BgImage vertical(Side side, double size, boolean asPercent) {
+ sideV = side;
+ posV = size;
+ posVAsPercent = asPercent;
+ return this;
+ }
+
+ /**
+ * 居上
+ *
+ * @param size 距离
+ * @return 本实例
+ */
+ public BgImage top(double size) {
+ return vertical(Side.TOP, size, false);
+ }
+
+
+ /**
+ * 居下
+ *
+ * @param size 距离
+ * @return 本实例
+ */
+ public BgImage bottom(double size) {
+ return vertical(Side.BOTTOM, size, false);
+ }
+
+ /**
+ * 坐标轴定位
+ *
+ * @param x 轴
+ * @param y 轴
+ * @return 本实例
+ */
+ public BgImage xy(double x, double y) {
+ sideH = Side.LEFT;
+ sideV = Side.TOP;
+ posH = x;
+ posV = y;
+ posHAsPercent = posVAsPercent = false;
+ return this;
+ }
+
+ /**
+ * 图像大小
+ *
+ * @param width 宽度
+ * @param height 高度
+ * @param widthAsPercent true 为参数是百分比
+ * @param heightAsPercent true 为参数是百分比
+ * @param isContain true 为尽量最大化图像
+ * @param isCover true 为保持比例
+ * @return 本实例
+ */
+ public BgImage size(double width, double height, boolean widthAsPercent, boolean heightAsPercent, boolean isContain, boolean isCover) {
+ sizeW = width;
+ sizeH = height;
+ sizeWAsPercent = widthAsPercent;
+ sizeHAsPercent = heightAsPercent;
+ this.isContain = isContain;
+ this.isCover = isCover;
+ return this;
+ }
+
+ /**
+ * 自适应保持比例时尽量最大化的背景
+ *
+ * @return 本实例
+ */
+ public BgImage cover() {
+ return size(100, 100, true, true, true, true);
+ }
+
+ /**
+ * 居中背景图片
+ *
+ * @return 本实例
+ */
+ public BgImage center() {
+ sideH = Side.LEFT;
+ sideV = Side.TOP;
+ posH = posV = 0;
+ posHAsPercent = posVAsPercent = true;
+ return this;
+ }
+
+ /** @return 最终构造背景 */
+ public Background build() {
+ return new Background(new BackgroundImage(img, repeatX, repeatY, new BackgroundPosition(sideH, posH, posHAsPercent, sideV, posV, posVAsPercent), new BackgroundSize(sizeW, sizeH, sizeWAsPercent, sizeHAsPercent, isContain, isCover)));
+ }
+
+ /**
+ * JavaFX 设置组件背景,底色为默认
+ *
+ * @param node 节点
+ * @param url 背景路径
+ * @param width 宽度
+ * @param x X 轴偏移
+ * @param y Y 轴偏移
+ */
+ public static void setBg(Node node, String url, int width, int x, int y) {
+ node.setStyle("-fx-background-size: " + width + ";" + "-fx-background-image: url('" + url + "');" + "-fx-background-insets: 0;" + "-fx-background-repeat: no-repeat;" + "-fx-background-position: " + x + " " + y);
+ }
+
+ /**
+ * JavaFX 设置组件背景,底色为透明
+ *
+ * @param node 节点
+ * @param url 背景路径
+ * @param width 宽度
+ * @param x X 轴偏移
+ * @param y Y 轴偏移
+ */
+ public static void setBgTp(Node node, String url, int width, int x, int y) {
+ node.setStyle("-fx-background-size: " + width + ";" + "-fx-background-image: url('" + url + "');" + "-fx-background-color: transparent;" + "-fx-background-insets: 0;" + "-fx-background-repeat: no-repeat;" + "-fx-background-position: " + x + " " + y);
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/BorderStroke.java b/src/main/java/com/imyeyu/fx/utils/BorderStroke.java
new file mode 100644
index 0000000..46fc304
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/BorderStroke.java
@@ -0,0 +1,467 @@
+package com.imyeyu.fx.utils;
+
+import javafx.geometry.Insets;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.paint.Paint;
+
+import java.util.Random;
+
+/**
+ * 快速构建边框。{@link #build()} 作为导出作用,最后执行
+ *
+ *
示例
+ *
+ * new BorderStroke("red").build(); // 红色边框,默认样式(1 像素宽度直线,无圆角无内边距)
+ * new BorderStroke("red").width(0, 1, 0, 1).build() // 红色直线左右边框
+ * new BorderStroke("red").top().build() // 红色直线上边框
+ * new BorderStroke("red").radius(.5, true).build() // 红色完全圆角边框
+ *
+ * {@link #top()}, {@link #right()}, {@link #bottom()}, {@link #left()} 不可重复使用,因为这是针对性边框宽度设置,重复使用只有最后一个有效
+ *
+ * @author 夜雨
+ * @since 2021-02-14 10:42
+ */
+public class BorderStroke {
+
+ /** 空的边框 */
+ public static final Border EMPTY = Border.EMPTY;
+
+ /** 无边框样式 */
+ public static final BorderStrokeStyle NONE = BorderStrokeStyle.NONE;
+
+ /** 连续边框 */
+ public static final BorderStrokeStyle SOLID = BorderStrokeStyle.SOLID;
+
+ /** 断续边框 */
+ public static final BorderStrokeStyle DASHED = BorderStrokeStyle.DASHED;
+
+ /** 点状边框 */
+ public static final BorderStrokeStyle DOTTED = BorderStrokeStyle.DOTTED;
+
+ // 简写说明: TRBL 代表上右下左
+
+ /** 颜色 */
+ private final Paint cT, cR, cB, cL;
+
+ /** 宽度 */
+ private double wT, wR, wB, wL;
+
+ /** 圆角 */
+ private double crTL, crTR, crBR, crBL;
+
+ /** 内边距 */
+ private double pT, pR, pB, pL;
+
+ /** 圆角是否百分比 */
+ private boolean crPercent = false;
+
+ /** 样式 */
+ private BorderStrokeStyle bssT, bssR, bssB, bssL;
+
+ /**
+ * 边框构造
+ *
+ * @param top 上边框颜色
+ * @param right 右边框颜色
+ * @param bottom 下边框颜色
+ * @param left 左边框颜色
+ */
+ public BorderStroke(String top, String right, String bottom, String left) {
+ this(Paint.valueOf(top), Paint.valueOf(right), Paint.valueOf(bottom), Paint.valueOf(left));
+ }
+
+ /**
+ * 边框构造
+ *
+ * @param color 边框颜色
+ */
+ public BorderStroke(String color) {
+ this(color, color, color, color);
+ }
+
+ /**
+ * 边框构造
+ *
+ * @param color 边框颜色
+ */
+ public BorderStroke(Paint color) {
+ this(color, color, color, color);
+ }
+
+ /**
+ * 边框构造
+ *
+ * @param top 上边框颜色
+ * @param right 右边框颜色
+ * @param bottom 下边框颜色
+ * @param left 左边框颜色
+ */
+ public BorderStroke(Paint top, Paint right, Paint bottom, Paint left) {
+ cT = top;
+ cR = right;
+ cB = bottom;
+ cL = left;
+ // 默认参数
+ wT = wR = wB = wL = 1;
+ crTL = crTR = crBR = crBL = 0;
+ pT = pR = pB = pL = 0;
+ bssT = bssR = bssB = bssL = SOLID;
+ }
+
+ /**
+ * 1 像素上边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke top() {
+ return top(1);
+ }
+
+ /**
+ * 1 像素右边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke right() {
+ return right(1);
+ }
+
+ /**
+ * 1 像素下边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke bottom() {
+ return bottom(1);
+ }
+
+ /**
+ * 1 像素左边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke left() {
+ return left(1);
+ }
+
+ /**
+ * 上边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke top(double size) {
+ return width(size, 0, 0, 0);
+ }
+
+ /**
+ * 右边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke right(double size) {
+ return width(0, size, 0, 0);
+ }
+
+ /**
+ * 下边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke bottom(double size) {
+ return width(0, 0, size, 0);
+ }
+
+ /**
+ * 左边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke left(double size) {
+ return width(0, 0, 0, size);
+ }
+
+ /**
+ * 除了上边框,其他设置为 1 像素边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke exTop() {
+ return exTop(1);
+ }
+
+ /**
+ * 除了右边框,其他设置为 1 像素边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke exRight() {
+ return exRight(1);
+ }
+
+ /**
+ * 除了下边框,其他设置为 1 像素边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke exBottom() {
+ return exBottom(1);
+ }
+
+ /**
+ * 除了左边框,其他设置为 1 像素边框
+ *
+ * @return 构造器
+ */
+ public BorderStroke exLeft() {
+ return exLeft(1);
+ }
+
+ /**
+ * 除了上边框,其他设置边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke exTop(double size) {
+ return width(0, size, size, size);
+ }
+
+ /**
+ * 除了右边框,其他设置边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke exRight(double size) {
+ return width(size, 0, size, size);
+ }
+
+ /**
+ * 除了下边框,其他设置边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke exBottom(double size) {
+ return width(size, size, 0, size);
+ }
+
+ /**
+ * 除了左边框,其他设置边框
+ *
+ * @param size 大小
+ * @return 构造器
+ */
+ public BorderStroke exLeft(double size) {
+ return width(size, size, size, 0);
+ }
+
+ /**
+ * 设置宽度
+ *
+ * @param all 所有边框
+ * @return 构造器
+ */
+ public BorderStroke width(double all) {
+ return width(all, all);
+ }
+
+ /**
+ * 设置宽度
+ *
+ * @param topBottom 上下
+ * @param leftRight 左右
+ * @return 构造器
+ */
+ public BorderStroke width(double topBottom, double leftRight) {
+ return width(topBottom, leftRight, topBottom, leftRight);
+ }
+
+ /**
+ * 设置宽度
+ *
+ * @param top 上
+ * @param right 右
+ * @param bottom 下
+ * @param left 左
+ * @return 构造器
+ */
+ public BorderStroke width(double top, double right, double bottom, double left) {
+ wT = top;
+ wR = right;
+ wB = bottom;
+ wL = left;
+ return this;
+ }
+
+ /**
+ * 设置圆角
+ *
+ * @param all 所有大小
+ * @return 构造器
+ */
+ public BorderStroke radius(double all) {
+ return radius(all, false);
+ }
+
+ /**
+ * 设置圆角
+ *
+ * @param all 所有大小
+ * @param asPercent 是否为百分比值(默认否)
+ * @return 构造器
+ */
+ public BorderStroke radius(double all, boolean asPercent) {
+ return radius(all, all, all, all, asPercent);
+ }
+
+ /**
+ * 设置圆角
+ *
+ * @param topLeft 左上角大小
+ * @param topRight 右上角大小
+ * @param bottomRight 右下角大小
+ * @param bottomLeft 左下角大小
+ * @param asPercent 是否为百分比值
+ * @return 构造器
+ */
+ public BorderStroke radius(double topLeft, double topRight, double bottomRight, double bottomLeft, boolean asPercent) {
+ crTL = topLeft;
+ crTR = topRight;
+ crBR = bottomRight;
+ crBL = bottomLeft;
+ crPercent = asPercent;
+ return this;
+ }
+
+ /**
+ * 虚线样式
+ *
+ * @return 构造器
+ */
+ public BorderStroke dashed() {
+ return style(DASHED);
+ }
+
+ /**
+ * 点阵样式
+ *
+ * @return 构造器
+ */
+ public BorderStroke dotted() {
+ return style(DOTTED);
+ }
+
+ /**
+ * 设置样式
+ *
+ * @param all 所有边框样式
+ * @return 构造器
+ */
+ public BorderStroke style(BorderStrokeStyle all) {
+ return style(all, all, all, all);
+ }
+
+ /**
+ * 设置样式
+ *
+ * @param top 上
+ * @param right 右
+ * @param bottom 下
+ * @param left 左
+ * @return 构造器
+ */
+ public BorderStroke style(BorderStrokeStyle top, BorderStrokeStyle right, BorderStrokeStyle bottom, BorderStrokeStyle left) {
+ bssT = top;
+ bssR = right;
+ bssB = bottom;
+ bssL = left;
+ return this;
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param all 所有边框内边距
+ * @return 边框构造器
+ */
+ public BorderStroke padding(double all) {
+ return padding(all, all);
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param topBottom 上下
+ * @param leftRight 左右
+ * @return 边框构造器
+ */
+ public BorderStroke padding(double topBottom, double leftRight) {
+ return padding(topBottom, leftRight, topBottom, leftRight);
+ }
+
+ /**
+ * 设置内边距
+ *
+ * @param top 上
+ * @param right 右
+ * @param bottom 下
+ * @param left 左
+ * @return 边框构造器
+ */
+ public BorderStroke padding(double top, double right, double bottom, double left) {
+ pT = top;
+ pR = right;
+ pB = bottom;
+ pL = left;
+ return this;
+ }
+
+ /**
+ * 构造边框
+ *
+ * @return 边框
+ */
+ public Border build() {
+ return new Border(
+ new javafx.scene.layout.BorderStroke(
+ cT, cR, cB, cL,
+ bssT, bssR, bssB, bssL,
+ new CornerRadii(crTL, crTR, crBR, crBL, crPercent),
+ new BorderWidths(wT, wR, wB, wL),
+ new Insets(pT, pR, pB, pL)
+ )
+ );
+ }
+
+ /**
+ * 快速构造测试边框
+ *
+ * @param color 颜色
+ * @return 边框
+ */
+ public static Border test(String color) {
+ return new BorderStroke(color).build();
+ }
+
+ /**
+ * 快速构造测试边框
+ *
+ * @return 边框
+ */
+ public static Border test() {
+ Random r = new Random();
+ StringBuilder sb = new StringBuilder("#");
+ for (int i = 0; i < 6; i++) {
+ sb.append(r.nextInt(8));
+ }
+ return test(sb.toString());
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/Column.java b/src/main/java/com/imyeyu/fx/utils/Column.java
new file mode 100644
index 0000000..add0e3a
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/Column.java
@@ -0,0 +1,175 @@
+package com.imyeyu.fx.utils;
+
+import javafx.geometry.HPos;
+import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.Priority;
+
+/**
+ * GridPane 列属性构造
+ *
+ * @author 夜雨
+ * @since 2021-12-17 23:45
+ */
+public class Column extends ColumnConstraints {
+
+ /** 通用键列 */
+ public static final Column KEY = Column.build();
+
+ /** 通用值列 */
+ public static final Column VALUE = Column.build().notFill().alwaysPriority();
+
+ /** 通用值列(填充) */
+ public static final Column VALUE_FILL = Column.build().fill().alwaysPriority();
+
+ /**
+ * 默认构造器({@link #build(HPos)} 静态构造)
+ *
+ * @param align 水平对齐方式
+ */
+ private Column(HPos align) {
+ setHalignment(align);
+ }
+
+ /**
+ * 设置最小宽度
+ *
+ * @param minWidth 最小宽度
+ * @return 本实例
+ */
+ public Column min(double minWidth) {
+ setMinWidth(minWidth);
+ return this;
+ }
+
+ /**
+ * 设置最大宽度
+ *
+ * @param maxWidth 最大宽度
+ * @return 本实例
+ */
+ public Column max(double maxWidth) {
+ setMaxWidth(maxWidth);
+ return this;
+ }
+
+ /**
+ * 设置宽度
+ *
+ * @param width 宽度
+ * @return 本实例
+ */
+ public Column width(double width) {
+ setPrefWidth(width);
+ return this;
+ }
+
+ /**
+ * 以百分比设置宽度
+ *
+ * @param percentWidth 宽度
+ * @return 本实例
+ */
+ public Column percentWidth(double percentWidth) {
+ setPercentWidth(percentWidth);
+ return this;
+ }
+
+ /**
+ * 保持子组件宽度填充
+ *
+ * @return 本实例
+ */
+ public Column fill() {
+ setFillWidth(true);
+ return this;
+ }
+
+ /**
+ * 子组件宽度不需填充
+ *
+ * @return 本实例
+ */
+ public Column notFill() {
+ setFillWidth(false);
+ return this;
+ }
+
+ /**
+ * 左对齐
+ *
+ * @return 本实例
+ */
+ public Column left() {
+ setHalignment(HPos.LEFT);
+ return this;
+ }
+
+ /**
+ * 居中对齐
+ *
+ * @return 本实例
+ */
+ public Column center() {
+ setHalignment(HPos.CENTER);
+ return this;
+ }
+
+ /**
+ * 右对齐
+ *
+ * @return 本实例
+ */
+ public Column right() {
+ setHalignment(HPos.RIGHT);
+ return this;
+ }
+
+ /**
+ * 列宽总是保持跟随容器尺寸变化
+ *
+ * @return 本实例
+ */
+ public Column alwaysPriority() {
+ setHgrow(Priority.ALWAYS);
+ return this;
+ }
+
+ /**
+ * 列宽总是不保持跟随容器尺寸变化
+ *
+ * @return 本实例
+ */
+ public Column neverPriority() {
+ setHgrow(Priority.NEVER);
+ return this;
+ }
+
+ /**
+ * 列宽不受其他列影响时可跟随容器尺寸变化
+ *
+ * @return 本实例
+ */
+ public Column sometimesPriority() {
+ setHgrow(Priority.SOMETIMES);
+ return this;
+ }
+
+ /**
+ * 静态构造列属性对象
+ *
+ * @return 列属性对象
+ */
+ public static Column build() {
+ return new Column(HPos.LEFT);
+ }
+
+ /**
+ * 静态构造列属性对象
+ *
+ * @param align 水平对齐方式
+ * @return 本实例
+ */
+ public static Column build(HPos align) {
+ return new Column(align);
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/DirectorySelector.java b/src/main/java/com/imyeyu/fx/utils/DirectorySelector.java
new file mode 100644
index 0000000..25772fe
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/DirectorySelector.java
@@ -0,0 +1,72 @@
+package com.imyeyu.fx.utils;
+
+import javafx.stage.DirectoryChooser;
+import javafx.stage.Window;
+
+import java.io.File;
+import java.util.Objects;
+
+/**
+ * 文件夹选择器
+ *
+ * @author 夜雨
+ * @version 2022-04-28 17:48
+ */
+public class DirectorySelector {
+
+ /** 选择器 */
+ private final DirectoryChooser chooser;
+
+ /** 默认构造器,路径为当前程序运行路径 */
+ public DirectorySelector() {
+ this("./");
+ }
+
+ /**
+ * 构造器
+ *
+ * @param path 默认路径
+ */
+ public DirectorySelector(String path) {
+ chooser = new DirectoryChooser();
+
+ File dir = new File(Objects.requireNonNullElse(path, ""));
+ if (dir.exists()) {
+ if (dir.isDirectory()) {
+ chooser.setInitialDirectory(dir);
+ } else {
+ chooser.setInitialDirectory(dir.getParentFile());
+ }
+ } else {
+ chooser.setInitialDirectory(new File("./"));
+ }
+ }
+
+ /**
+ * 设置标题
+ *
+ * @param title 标题
+ */
+ public void setTitle(String title) {
+ chooser.setTitle(title);
+ }
+
+ /**
+ * 显示文件夹选择
+ *
+ * @param window 依赖窗体
+ * @return 选择的文件夹
+ */
+ public File show(Window window) {
+ return chooser.showDialog(window);
+ }
+
+ /**
+ * 获取选择器
+ *
+ * @return 选择器
+ */
+ public DirectoryChooser getChooser() {
+ return chooser;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/imyeyu/fx/utils/FileSelector.java b/src/main/java/com/imyeyu/fx/utils/FileSelector.java
new file mode 100644
index 0000000..9d348e0
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/FileSelector.java
@@ -0,0 +1,103 @@
+package com.imyeyu.fx.utils;
+
+import javafx.stage.FileChooser;
+import javafx.stage.Window;
+
+import java.io.File;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 文件选择器
+ *
+ * @author 夜雨
+ * @version 2022-04-14 19:38
+ */
+public class FileSelector {
+
+ /** 选择器 */
+ private final FileChooser chooser;
+
+ /** 默认构造器 */
+ public FileSelector() {
+ this("./");
+ }
+
+ /**
+ * 构造文件选择器
+ *
+ * @param path 默认路径
+ */
+ public FileSelector(String path) {
+ chooser = new FileChooser();
+
+ File dir = new File(Objects.requireNonNullElse(path, ""));
+ if (dir.exists()) {
+ if (dir.isFile()) {
+ chooser.setInitialDirectory(dir.getParentFile());
+ } else {
+ chooser.setInitialDirectory(dir);
+ }
+ } else {
+ chooser.setInitialDirectory(new File("./"));
+ }
+ }
+
+ /**
+ * 设置标题
+ *
+ * @param title 标题
+ */
+ public void setTitle(String title) {
+ chooser.setTitle(title);
+ }
+
+ /**
+ * 添加格式限制
+ *
+ * @param description 说明
+ * @param format 格式
+ */
+ public void addFilter(String description, String... format) {
+ chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(description, format));
+ }
+
+ /**
+ * 单选文件
+ *
+ * @param window 依赖窗体
+ * @return 选择的文件
+ */
+ public File single(Window window) {
+ return chooser.showOpenDialog(window);
+ }
+
+ /**
+ * 多选文件
+ *
+ * @param window 依赖窗体
+ * @return 选择的文件
+ */
+ public List multi(Window window) {
+ return chooser.showOpenMultipleDialog(window);
+ }
+
+ /**
+ * 保存文件
+ *
+ * @param window 依赖窗体
+ * @return 选择的文件夹
+ */
+ public File save(Window window) {
+ return chooser.showSaveDialog(window);
+ }
+
+ /**
+ * 获取选择器
+ *
+ * @return 选择器
+ */
+ public FileChooser getChooser() {
+ return chooser;
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/NoSelectionModel.java b/src/main/java/com/imyeyu/fx/utils/NoSelectionModel.java
new file mode 100644
index 0000000..f90d609
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/NoSelectionModel.java
@@ -0,0 +1,53 @@
+package com.imyeyu.fx.utils;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.scene.control.MultipleSelectionModel;
+
+/**
+ * 空的选择器,适用于 ListView
+ *
+ * @param 数据类型
+ *
+ * 夜雨 创建于 2021-05-16 23:29
+ */
+public class NoSelectionModel extends MultipleSelectionModel {
+
+ public ObservableList getSelectedIndices() {
+ return FXCollections.emptyObservableList();
+ }
+
+ public ObservableList getSelectedItems() {
+ return FXCollections.emptyObservableList();
+ }
+
+ public void selectIndices(int index, int... indices) {}
+
+ public void selectAll() {}
+
+ public void selectFirst() {}
+
+ public void selectLast() {}
+
+ public void clearAndSelect(int index) {}
+
+ public void select(int index) {}
+
+ public void select(T obj) {}
+
+ public void clearSelection(int index) {}
+
+ public void clearSelection() {}
+
+ public boolean isSelected(int index) {
+ return false;
+ }
+
+ public boolean isEmpty() {
+ return true;
+ }
+
+ public void selectPrevious() {}
+
+ public void selectNext() {}
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/Row.java b/src/main/java/com/imyeyu/fx/utils/Row.java
new file mode 100644
index 0000000..992b249
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/Row.java
@@ -0,0 +1,165 @@
+package com.imyeyu.fx.utils;
+
+import javafx.geometry.VPos;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.RowConstraints;
+
+/**
+ * GridPane 行属性构造
+ *
+ * @author 夜雨
+ * @since 2021-12-17 23:45
+ */
+public class Row extends RowConstraints {
+
+ /**
+ * 默认构造器({@link #build(VPos)} 静态构造)
+ *
+ * @param align 垂直对齐方式
+ */
+ private Row(VPos align) {
+ setValignment(align);
+ }
+
+ /**
+ * 设置最小高度
+ *
+ * @param minHeight 最小高度
+ * @return 本实例
+ */
+ public Row min(double minHeight) {
+ setMinHeight(minHeight);
+ return this;
+ }
+
+ /**
+ * 设置最大高度
+ *
+ * @param maxHeight 最大高度
+ * @return 本实例
+ */
+ public Row max(double maxHeight) {
+ setMaxHeight(maxHeight);
+ return this;
+ }
+
+ /**
+ * 设置高度
+ *
+ * @param height 高度
+ * @return 本实例
+ */
+ public Row height(double height) {
+ setPrefHeight(height);
+ return this;
+ }
+
+ /**
+ * 以百分比设置高度
+ *
+ * @param percentHeight 高度
+ * @return 本实例
+ */
+ public Row percentHeight(double percentHeight) {
+ setPercentHeight(percentHeight);
+ return this;
+ }
+
+ /**
+ * 保持子组件高度填充
+ *
+ * @return 本实例
+ */
+ public Row fill() {
+ setFillHeight(true);
+ return this;
+ }
+
+ /**
+ * 子组件高度不需填充
+ *
+ * @return 本实例
+ */
+ public Row notFill() {
+ setFillHeight(false);
+ return this;
+ }
+
+ /**
+ * 顶部对齐
+ *
+ * @return 本实例
+ */
+ public Row top() {
+ setValignment(VPos.TOP);
+ return this;
+ }
+
+ /**
+ * 居中对齐
+ *
+ * @return 本实例
+ */
+ public Row center() {
+ setValignment(VPos.CENTER);
+ return this;
+ }
+ /**
+ * 底部对齐
+ *
+ * @return 本实例
+ */
+ public Row bottom() {
+ setValignment(VPos.BOTTOM);
+ return this;
+ }
+
+ /**
+ * 行高总是保持跟随容器尺寸变化
+ *
+ * @return 本实例
+ */
+ public Row alwaysPriority() {
+ setVgrow(Priority.ALWAYS);
+ return this;
+ }
+
+ /**
+ * 行高总是不保持跟随容器尺寸变化
+ *
+ * @return 本实例
+ */
+ public Row neverPriority() {
+ setVgrow(Priority.NEVER);
+ return this;
+ }
+
+ /**
+ * 行高不受其他行影响时可跟随容器尺寸变化
+ *
+ * @return 本实例
+ */
+ public Row sometimesPriority() {
+ setVgrow(Priority.SOMETIMES);
+ return this;
+ }
+
+ /**
+ * 静态构造行属性对象
+ *
+ * @return 行属性对象
+ */
+ public static Row build() {
+ return new Row(VPos.CENTER);
+ }
+
+ /**
+ * 静态构造行属性对象
+ *
+ * @param align 垂直对齐方式
+ * @return 本实例
+ */
+ public static Row build(VPos align) {
+ return new Row(align);
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/ScreenFX.java b/src/main/java/com/imyeyu/fx/utils/ScreenFX.java
new file mode 100644
index 0000000..51c78ec
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/ScreenFX.java
@@ -0,0 +1,122 @@
+package com.imyeyu.fx.utils;
+
+import javafx.collections.ListChangeListener;
+import javafx.geometry.BoundingBox;
+import javafx.geometry.Bounds;
+import javafx.geometry.Rectangle2D;
+import javafx.stage.Screen;
+import javafx.stage.Stage;
+import com.imyeyu.utils.Digest;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 多屏操作,可在所有屏幕显示标识,并提供选择参考
+ *
+ * @author 夜雨
+ * @since 2021-11-11 21:02
+ */
+public class ScreenFX extends Stage {
+
+ /** 主屏幕 */
+ public static final Screen primary = Screen.getPrimary();
+
+ /** 所有屏幕 */
+ public static final List SCREENS;
+
+ static {
+ SCREENS = new ArrayList<>(Screen.getScreens());
+ SCREENS.sort((s1, s2) -> (int) (s2.getBounds().getMinX() - s1.getBounds().getMinX()));
+ Screen.getScreens().addListener((ListChangeListener) c -> {
+ SCREENS.clear();
+ SCREENS.addAll(Screen.getScreens());
+ SCREENS.sort((s1, s2) -> (int) (s2.getBounds().getMinX() - s1.getBounds().getMinX()));
+ });
+ }
+
+ // ---------- 静态功能 ----------
+
+ /**
+ * 此屏幕参数 MD5
+ *
+ * @param screen 屏幕
+ * @return MD5
+ */
+ public static String md5(Screen screen) throws NoSuchAlgorithmException {
+ Rectangle2D r = screen.getBounds();
+ return Digest.md5(String.valueOf(r.getMinX()) + r.getMinY() + r.getMaxX() + r.getMaxY() + r.getWidth() + r.getHeight());
+ }
+
+ /**
+ * 获取坐标所在屏幕
+ *
+ * @param x 坐标
+ * @param y 坐标
+ * @return 屏幕(在所有屏幕之外时返回 null)
+ */
+ public static Screen getScreenByXY(double x, double y) {
+ for (int i = 0; i < SCREENS.size(); i++) {
+ if (isInScreen(SCREENS.get(i), x, y)) {
+ return SCREENS.get(i);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 该坐标是否溢出屏幕
+ *
+ * @param x 坐标
+ * @param y 坐标
+ * @return true 为溢出
+ */
+ public static boolean outOfScreen(double x, double y) {
+ return getScreenByXY(x, y) == null;
+ }
+
+ /**
+ * 判定该坐标是否在屏幕内
+ *
+ * @param screen 屏幕
+ * @param x 坐标
+ * @param y 坐标
+ * @return true 为存在
+ */
+ public static boolean isInScreen(Screen screen, double x, double y) {
+ final Rectangle2D r = screen.getBounds();
+ return !(x < r.getMinX() || r.getMaxX() < x || y < r.getMinY() || r.getMaxY() < y);
+ }
+
+ /**
+ * 获取任务栏属性
+ *
+ * @param screen 屏幕
+ * @return 任务栏属性
+ */
+ public static Bounds getTaskbarBounds(Screen screen) {
+ if (screen == null) {
+ return null;
+ }
+ Rectangle2D sb = screen.getBounds();
+ Rectangle2D vb = screen.getVisualBounds();
+ if (sb.getMinY() < vb.getMinY()) {
+ // 顶
+ return new BoundingBox(sb.getMinX(), sb.getMinY(), sb.getWidth(), sb.getHeight() - vb.getHeight());
+ }
+ if (vb.getMaxY() < sb.getMaxY()) {
+ // 底
+ return new BoundingBox(sb.getMinX(), vb.getMaxY(), sb.getWidth(), sb.getHeight() - vb.getHeight());
+ }
+ if (sb.getMinX() < vb.getMinX()) {
+ // 左
+ return new BoundingBox(sb.getMinX(), sb.getMinY(), sb.getWidth() - vb.getWidth(), sb.getHeight());
+ }
+ if (vb.getMaxX() < sb.getMaxX()) {
+ // 右
+ return new BoundingBox(vb.getMaxX(), sb.getMinY(), sb.getWidth() - vb.getWidth(), sb.getHeight());
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/SmoothScroll.java b/src/main/java/com/imyeyu/fx/utils/SmoothScroll.java
new file mode 100644
index 0000000..cab5c87
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/SmoothScroll.java
@@ -0,0 +1,372 @@
+package com.imyeyu.fx.utils;
+
+import com.sun.javafx.scene.control.VirtualScrollBar;
+import javafx.animation.Animation;
+import javafx.animation.KeyFrame;
+import javafx.animation.KeyValue;
+import javafx.animation.Timeline;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.event.EventHandler;
+import javafx.geometry.Bounds;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Control;
+import javafx.scene.control.ListView;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TableView;
+import javafx.scene.control.TextArea;
+import javafx.scene.control.TreeTableView;
+import javafx.scene.control.TreeView;
+import javafx.scene.control.skin.ComboBoxListViewSkin;
+import javafx.scene.control.skin.VirtualFlow;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.input.ScrollEvent;
+import javafx.util.Duration;
+import com.imyeyu.fx.bean.Interpolates;
+import com.imyeyu.java.bean.Callback;
+import com.imyeyu.java.bean.CallbackArg;
+import com.imyeyu.java.ref.Ref;
+
+import java.util.function.Function;
+
+/**
+ * 平滑滚动控制,使控件滚动平滑,此功能需要较多 GPU 性能
+ *
+ * 注意:虚拟滚动面板需要通过列表项计算理论可滚动高度,所以列表项必须统一高度
+ *
+ *
+ * SmoothScroll.textarea(new TextArea()); // 文本域平滑滚动
+ * SmoothScroll.scrollPane(new ScrollPane()); // 平滑滚动面板
+ * SmoothScroll.virtual(new ListView()); // 列表平滑滚动(虚拟面板平滑滚动,表格、树形结构等懒加载组件都是虚拟面板)
+ * SmoothScroll.reflectSkin(new TextArea(), "scrollPane"); // 自定反射皮肤平滑滚动面板(反射组件 skin 里的滚动面板)
+ *
+ *
+ * @author 夜雨
+ * @since 2022-05-24 16:04
+ */
+public class SmoothScroll {
+
+ private static final double[] FRICTIONS = {.99, .1, .05, .04, .03, .02, .01, .04, .01, .008, .008, .008, .008, .0006, .0005, .00003, .00001};
+ private static final double[] PUSHES = {.5};
+ private static final double[] SCROLL_TO_FRICTIONS = Interpolates.EASE_OUT_QUINT.buildBezierPointValue(75);
+
+ // ---------- 虚拟面板滚动(列表项需统一高度) ----------
+
+
+ /**
+ * 平滑滚动 ComboBox 的选项列表
+ *
+ * @param comboBox 选择器
+ * @deprecated 1.3.4 过时,1.6.0 移除,请使用 {@link #comboBox(ComboBox)}
+ */
+ @Deprecated
+ public static void combobox(ComboBox> comboBox) {
+ comboBox(comboBox);
+ }
+
+ /**
+ * 平滑滚动 ComboBox 的选项列表
+ *
+ * @param comboBox 选择器
+ */
+ public static void comboBox(ComboBox> comboBox) {
+ if (comboBox.getSkin() == null) {
+ comboBox.skinProperty().addListener((obs, o, obj) -> {
+ try {
+ virtual(Ref.getClassFieldValue(obj, ComboBoxListViewSkin.class, "listView", ListView.class));
+ } catch (IllegalAccessException | NoSuchFieldException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } else {
+ try {
+ virtual(Ref.getClassFieldValue(comboBox.getSkin(), ComboBoxListViewSkin.class, "listView", ListView.class));
+ } catch (IllegalAccessException | NoSuchFieldException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * 反射控件虚拟滚动面板
+ *
+ * @param control 控件
+ */
+ public static void virtual(Control control) {
+ if (control.getSkin() == null) {
+ control.skinProperty().addListener((obs, o, obj) -> {
+ try {
+ virtual(control, Ref.getFieldValue(obj, "flow", VirtualFlow.class));
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } else {
+ try {
+ virtual(control, Ref.getFieldValue(control.getSkin(), "flow", VirtualFlow.class));
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * 虚拟滚动面板平滑滚动
+ *
+ * @param control 组件
+ * @param virtualFlow 虚拟滚动面板
+ */
+ public static void virtual(Control control, VirtualFlow> virtualFlow) {
+ try {
+ VirtualScrollBar vbar = Ref.getFieldValue(virtualFlow, "vbar", VirtualScrollBar.class);
+ DoubleProperty theoreticalHeight = new SimpleDoubleProperty(0); // 理论总高度
+ DoubleProperty cellHeight = new SimpleDoubleProperty(0); // 行高
+
+ final double[] derivatives = new double[FRICTIONS.length];
+ final double[] lastVPos = {0};
+
+ vbar.setUnitIncrement(5);
+
+ // 列表项高度和理论高度计算
+ Callback calcHeight = () -> {
+ if (0 < virtualFlow.getCellCount() && virtualFlow.getCell(0) != null) {
+ cellHeight.set(virtualFlow.getCell(0).getHeight());
+ theoreticalHeight.set(virtualFlow.getCellCount() * cellHeight.get());
+ }
+ };
+
+ Timeline timeline = new Timeline();
+ final EventHandler dragHandler = event -> timeline.stop();
+ final EventHandler scrollHandler = event -> {
+ calcHeight.handler();
+ if (virtualFlow.getHeight() < theoreticalHeight.get()) {
+ vbar.valueProperty().set(lastVPos[0]);
+ if (event.getEventType() == ScrollEvent.SCROLL) {
+ double direction = 0 < event.getDeltaY() ? -1 : 1;
+ for (int j = 0; j < PUSHES.length; j++) {
+ derivatives[j] += direction * PUSHES[j];
+ }
+ if (timeline.getStatus() == Animation.Status.STOPPED) {
+ timeline.play();
+ }
+ }
+ }
+ event.consume();
+ };
+ Callback calcScrollKeyFrame = () -> {
+ timeline.stop();
+ timeline.getKeyFrames().clear();
+ timeline.getKeyFrames().add(new KeyFrame(Duration.millis(.6), e -> {
+ for (int j = 0; j < derivatives.length; j++) {
+ derivatives[j] *= FRICTIONS[j];
+ }
+ for (int j = 1; j < derivatives.length; j++) {
+ derivatives[j] += derivatives[j - 1];
+ }
+ double dy = derivatives[derivatives.length - 1];
+ vbar.valueProperty().set(Math.min(Math.max(vbar.getValue() + dy / theoreticalHeight.get(), 0), 1));
+ lastVPos[0] = vbar.getValue();
+ if (Math.abs(dy) < 1) {
+ if (Math.abs(dy) < .001) {
+ timeline.stop();
+ }
+ }
+ }));
+ };
+ virtualFlow.heightProperty().addListener((obs, o, h) -> calcHeight.handler());
+ virtualFlow.cellCountProperty().addListener((obs, o, cell) -> {
+ vbar.setValue(lastVPos[0]);
+ calcHeight.handler();
+ calcScrollKeyFrame.handler();
+ });
+ calcScrollKeyFrame.handler();
+ if (vbar.getParent() != null) {
+ vbar.getParent().addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
+ vbar.getParent().addEventHandler(ScrollEvent.ANY, scrollHandler);
+ }
+ vbar.setOnMouseReleased(e -> lastVPos[0] = vbar.getValue());
+ vbar.parentProperty().addListener((obs, o, n) -> {
+ if (o != null) {
+ o.removeEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
+ o.removeEventHandler(ScrollEvent.ANY, scrollHandler);
+ }
+ if (n != null) {
+ n.addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
+ n.addEventHandler(ScrollEvent.ANY, scrollHandler);
+ }
+ });
+ timeline.setCycleCount(Animation.INDEFINITE);
+
+ // scrollTo 的动画实现
+ Timeline scrollToTimeLine = new Timeline();
+ CallbackArg buildScrollToFrame = index -> {
+ if (scrollToTimeLine.getStatus() == Animation.Status.RUNNING) {
+ scrollToTimeLine.stop();
+ }
+ scrollToTimeLine.getKeyFrames().clear();
+
+ double fromValue = vbar.getValue();
+ double offset = virtualFlow.getHeight() / cellHeight.get() * cellHeight.get();
+ double toValue = cellHeight.get() * index / (theoreticalHeight.get() - offset);
+ if (1 <= toValue) {
+ toValue = lastVPos[0] = 1;
+ }
+ double between = toValue - fromValue;
+ lastVPos[0] = toValue;
+ if (vbar.getValue() == toValue) {
+ return;
+ }
+
+ double keyMillis = 260D / SCROLL_TO_FRICTIONS.length;
+ KeyFrame frame;
+ for (int i = 0; i < SCROLL_TO_FRICTIONS.length; i++) {
+ double endValue;
+ if (between < 0) {
+ endValue = fromValue - Math.abs(between) * SCROLL_TO_FRICTIONS[i];
+ } else {
+ endValue = fromValue + Math.abs(between) * SCROLL_TO_FRICTIONS[i];
+ }
+ frame = new KeyFrame(Duration.millis(i * keyMillis), new KeyValue(vbar.valueProperty(), endValue));
+ scrollToTimeLine.getKeyFrames().add(frame);
+ }
+ };
+
+ if (control instanceof TableView> tableView) {
+ tableView.setOnScrollTo(e -> {
+ buildScrollToFrame.handler(e.getScrollTarget());
+ scrollToTimeLine.play();
+ });
+ }
+ if (control instanceof ListView> listView) {
+ listView.setOnScrollTo(e -> {
+ buildScrollToFrame.handler(e.getScrollTarget());
+ scrollToTimeLine.play();
+ });
+ }
+ if (control instanceof TreeView> treeView) {
+ treeView.setOnScrollTo(e -> {
+ buildScrollToFrame.handler(e.getScrollTarget());
+ scrollToTimeLine.play();
+ });
+ }
+ if (control instanceof TreeTableView> treeTableView) {
+ treeTableView.setOnScrollTo(e -> {
+ buildScrollToFrame.handler(e.getScrollTarget());
+ scrollToTimeLine.play();
+ });
+ }
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // ---------- 滚动面板 ----------
+
+ /**
+ * 文本域平滑滚动
+ *
+ * @param textArea 文本域
+ */
+ public static void textarea(TextArea textArea) {
+ reflectSkin(textArea, "scrollPane");
+ }
+
+ /**
+ * 反射控件平滑滚动面板
+ *
+ * @param control 控件
+ * @param fieldName 反射字段
+ */
+ public static void reflectSkin(Control control, String fieldName) {
+ control.skinProperty().addListener((obs, o, skin) -> {
+ try {
+ scrollPaneV(Ref.getFieldValue(skin, fieldName, ScrollPane.class));
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException("not found reflect control ScrollPane", e);
+ }
+ });
+ }
+
+ /**
+ * 平滑滚动面板
+ *
+ * @param scrollPane 滚动面板
+ */
+ public static void scrollPane(ScrollPane scrollPane) {
+ scrollPane(scrollPane, scrollPane.vvalueProperty(), Bounds::getHeight);
+ scrollPane(scrollPane, scrollPane.hvalueProperty(), Bounds::getWidth);
+ }
+
+ /**
+ * 平滑垂直滚动面板
+ *
+ * @param scrollPane 滚动面板
+ */
+ public static void scrollPaneV(ScrollPane scrollPane) {
+ scrollPane(scrollPane, scrollPane.vvalueProperty(), Bounds::getHeight);
+ }
+
+ /**
+ * 平滑水平滚动面板
+ *
+ * @param scrollPane 滚动面板
+ */
+ public static void scrollPaneH(ScrollPane scrollPane) {
+ scrollPane(scrollPane, scrollPane.hvalueProperty(), Bounds::getWidth);
+ }
+
+ /**
+ * 平滑滚动面板
+ *
+ * @param scrollPane 滚动面板
+ * @param scrollProperty 滚动轴属性
+ * @param sizeFunc 高度回调
+ */
+ public static void scrollPane(ScrollPane scrollPane, DoubleProperty scrollProperty, Function sizeFunc) {
+ final double[] derivatives = new double[FRICTIONS.length];
+
+ Timeline timeline = new Timeline();
+ final EventHandler dragHandler = event -> timeline.stop();
+ final EventHandler scrollHandler = event -> {
+ if (event.getEventType() == ScrollEvent.SCROLL) {
+ int direction = 0 < event.getDeltaY() ? -1 : 1;
+ for (int i = 0; i < PUSHES.length; i++) {
+ derivatives[i] += direction * PUSHES[i];
+ }
+ if (timeline.getStatus() == Animation.Status.STOPPED) {
+ timeline.play();
+ }
+ event.consume();
+ }
+ };
+ if (scrollPane.getContent().getParent() != null) {
+ scrollPane.getContent().getParent().addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
+ scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler);
+ }
+ scrollPane.getContent().parentProperty().addListener((obs, o, n) -> {
+ if (o != null) {
+ o.removeEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
+ o.removeEventHandler(ScrollEvent.ANY, scrollHandler);
+ }
+ if (n != null) {
+ n.addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
+ n.addEventHandler(ScrollEvent.ANY, scrollHandler);
+ }
+ });
+ timeline.getKeyFrames().add(new KeyFrame(Duration.millis(.6), (event) -> {
+ for (int i = 0; i < derivatives.length; i++) {
+ derivatives[i] *= FRICTIONS[i];
+ }
+ for (int i = 1; i < derivatives.length; i++) {
+ derivatives[i] += derivatives[i - 1];
+ }
+ double dy = derivatives[derivatives.length - 1];
+ double size = sizeFunc.apply(scrollPane.getContent().getLayoutBounds());
+ scrollProperty.set(Math.min(Math.max(scrollProperty.get() + dy / size, 0), 1));
+ if (Math.abs(dy) < .001) {
+ timeline.stop();
+ }
+ }));
+ timeline.setCycleCount(Animation.INDEFINITE);
+ }
+}
diff --git a/src/main/java/com/imyeyu/fx/utils/StringConverters.java b/src/main/java/com/imyeyu/fx/utils/StringConverters.java
new file mode 100644
index 0000000..e61a44d
--- /dev/null
+++ b/src/main/java/com/imyeyu/fx/utils/StringConverters.java
@@ -0,0 +1,68 @@
+package com.imyeyu.fx.utils;
+
+import javafx.util.StringConverter;
+import com.imyeyu.java.ref.Ref;
+import com.imyeyu.utils.Time;
+
+import java.text.ParseException;
+import java.time.LocalDate;
+
+/**
+ * FX 常用字符串转换
+ *
+ * @author 夜雨
+ * @since 2023-02-01 19:51
+ */
+public class StringConverters {
+
+ /** 日期转换 */
+ public static StringConverter DATE = new StringConverter<>() {
+
+ @Override
+ public String toString(LocalDate date) {
+ if (date == null) {
+ return null;
+ } else {
+ return Time.toDate(Time.fromLocalDate(date));
+ }
+ }
+
+ @Override
+ public LocalDate fromString(String string) {
+ try {
+ return Time.toLocalDateTime(Time.date.parse(string).getTime()).toLocalDate();
+ } catch (ParseException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+
+ /**
+ * 通用枚举转换,此转换反射枚举项的 name 字段
+ *
+ * @param enumClass 枚举类
+ * @return 转换器
+ * @param 枚举类型
+ */
+ public static > StringConverter type(Class enumClass) {
+ return new StringConverter<>() {
+
+ @Override
+ public String toString(T object) {
+ if (object == null) {
+ return "";
+ }
+ try {
+ return Ref.getFieldValue(object, "name", String.class);
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public T fromString(String string) {
+ return Ref.toType(enumClass, string);
+ }
+ };
+ }
+}
\ No newline at end of file