From 5182a3474e2e7334e65f4b5adb7e5f20517075c9 Mon Sep 17 00:00:00 2001 From: somehussar <148253652+somehussar@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:58:27 +0100 Subject: [PATCH] Field handler for automatic GUI creation --- .../java/somehussar/gui/FieldConstraint.java | 5 + .../java/somehussar/gui/FieldConstraints.java | 27 ++++ src/main/java/somehussar/gui/GuiEditable.java | 13 ++ .../java/somehussar/gui/GuiFieldHandler.java | 128 ++++++++++++++++++ .../gui/constraints/ConstraintFactory.java | 10 ++ .../gui/constraints/NumericConstraint.java | 13 ++ .../constraints/NumericConstraintFactory.java | 13 ++ .../constraints/NumericRangeConstraint.java | 35 +++++ .../somehussar/gui/field/EditableField.java | 71 ++++++++++ 9 files changed, 315 insertions(+) create mode 100644 src/main/java/somehussar/gui/FieldConstraint.java create mode 100644 src/main/java/somehussar/gui/FieldConstraints.java create mode 100644 src/main/java/somehussar/gui/GuiEditable.java create mode 100644 src/main/java/somehussar/gui/GuiFieldHandler.java create mode 100644 src/main/java/somehussar/gui/constraints/ConstraintFactory.java create mode 100644 src/main/java/somehussar/gui/constraints/NumericConstraint.java create mode 100644 src/main/java/somehussar/gui/constraints/NumericConstraintFactory.java create mode 100644 src/main/java/somehussar/gui/constraints/NumericRangeConstraint.java create mode 100644 src/main/java/somehussar/gui/field/EditableField.java diff --git a/src/main/java/somehussar/gui/FieldConstraint.java b/src/main/java/somehussar/gui/FieldConstraint.java new file mode 100644 index 000000000..af46df61d --- /dev/null +++ b/src/main/java/somehussar/gui/FieldConstraint.java @@ -0,0 +1,5 @@ +package somehussar.gui; + +public interface FieldConstraint { + void validate(Object value); +} diff --git a/src/main/java/somehussar/gui/FieldConstraints.java b/src/main/java/somehussar/gui/FieldConstraints.java new file mode 100644 index 000000000..113cf4460 --- /dev/null +++ b/src/main/java/somehussar/gui/FieldConstraints.java @@ -0,0 +1,27 @@ +package somehussar.gui; + +public final class FieldConstraints { + + private static final FieldConstraints EMPTY = + new FieldConstraints(new FieldConstraint[0]); + + private final FieldConstraint[] constraints; + + public FieldConstraints(FieldConstraint[] constraints) { + this.constraints = constraints; + } + + public static FieldConstraints empty() { + return EMPTY; + } + + public void validate(Object value) { + for (FieldConstraint c : constraints) { + c.validate(value); + } + } + + public boolean isEmpty() { + return constraints.length == 0; + } +} diff --git a/src/main/java/somehussar/gui/GuiEditable.java b/src/main/java/somehussar/gui/GuiEditable.java new file mode 100644 index 000000000..b32d4f7c4 --- /dev/null +++ b/src/main/java/somehussar/gui/GuiEditable.java @@ -0,0 +1,13 @@ +package somehussar.gui; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface GuiEditable { + + @Retention(RetentionPolicy.RUNTIME) + @interface Field { + String name() default ""; + } +} diff --git a/src/main/java/somehussar/gui/GuiFieldHandler.java b/src/main/java/somehussar/gui/GuiFieldHandler.java new file mode 100644 index 000000000..f217e2b5a --- /dev/null +++ b/src/main/java/somehussar/gui/GuiFieldHandler.java @@ -0,0 +1,128 @@ +package somehussar.gui; + +import somehussar.gui.constraints.*; +import somehussar.gui.field.EditableField; + +import java.lang.annotation.Annotation; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class GuiFieldHandler { + + private static final Map, ClassMetadata> CACHE = new HashMap<>(); + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + + public static final Map, ConstraintFactory> registry = new HashMap<>(); + + static { + registry.put(NumericConstraint.class, new NumericConstraintFactory()); + } + + + private GuiFieldHandler() {} + + public static ClassMetadata getMetadata(Class type) { + return CACHE.computeIfAbsent(type, GuiFieldHandler::scanClass); + } + + + private static ClassMetadata scanClass(Class type) { + if (!type.isAnnotationPresent(GuiEditable.class)) { + return null; + } + + ClassMetadata parent = null; + + Class superCls = type.getSuperclass(); + if (superCls != null && superCls != Object.class) { + parent = getMetadata(superCls); + } + + List list = new ArrayList<>(); + for (Field field : type.getDeclaredFields()) { + EditableField editableField = createEditableField(field); + + if (editableField != null) { + list.add(editableField); + } + } + + return new ClassMetadata( + type, + list.toArray(list.toArray(new EditableField[0])), + parent + ); + } + + @SuppressWarnings({"RawUseOfParameterized", "unchecked"}) + private static EditableField createEditableField(Field field) { + try { + GuiEditable.Field fieldConfig = field.getAnnotation(GuiEditable.Field.class); + if (fieldConfig == null) return null; + if (Modifier.isFinal(field.getModifiers())) return null; + + field.setAccessible(true); + EditableField.Builder builder = new EditableField.Builder(); + builder.name(fieldConfig.name()); + builder.type(field.getType()); + builder.getter(LOOKUP.unreflectGetter(field)); + builder.setter(LOOKUP.unreflectSetter(field)); + builder.constraints(buildConstraints(field)); + + + return builder.build(); + } catch (IllegalAccessException e) { + throw new RuntimeException( + "Failed to bind field: " + field, e); + } + } + + @SuppressWarnings({"RawUseOfParameterized", "unchecked"}) + private static FieldConstraints buildConstraints(Field field) { + List list = new ArrayList<>(); + + for (Annotation ann : field.getAnnotations()) { + ConstraintFactory factory = registry.get(ann.annotationType()); + if (factory != null) { + FieldConstraint c = factory.create(field, ann); + list.add(c); + } + } + + if (list.isEmpty()) return FieldConstraints.empty(); + return new FieldConstraints(list.toArray(new FieldConstraint[0])); + } + + + @SuppressWarnings({"RawUseOfParameterized"}) + public static final class ClassMetadata { + final Class type; + final EditableField[] fields; // only declared fields + final ClassMetadata parent; // superclass metadata (nullable) + + ClassMetadata(Class type, EditableField[] fields, ClassMetadata parent) { + this.type = type; + this.fields = fields; + this.parent = parent; + } + + public ClassMetadata getParent() { + return parent; + } + + public EditableField[] getDeclaredFields() { + return fields; + } + + public Class getType() { + return type; + } + } + + +} diff --git a/src/main/java/somehussar/gui/constraints/ConstraintFactory.java b/src/main/java/somehussar/gui/constraints/ConstraintFactory.java new file mode 100644 index 000000000..efc3cb90a --- /dev/null +++ b/src/main/java/somehussar/gui/constraints/ConstraintFactory.java @@ -0,0 +1,10 @@ +package somehussar.gui.constraints; + +import somehussar.gui.FieldConstraint; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; + +public interface ConstraintFactory { + FieldConstraint create(Field field, A annotation); +} diff --git a/src/main/java/somehussar/gui/constraints/NumericConstraint.java b/src/main/java/somehussar/gui/constraints/NumericConstraint.java new file mode 100644 index 000000000..773129be3 --- /dev/null +++ b/src/main/java/somehussar/gui/constraints/NumericConstraint.java @@ -0,0 +1,13 @@ +package somehussar.gui.constraints; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(value = RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface NumericConstraint { + double min() default Double.NEGATIVE_INFINITY; + double max() default Double.POSITIVE_INFINITY; +} diff --git a/src/main/java/somehussar/gui/constraints/NumericConstraintFactory.java b/src/main/java/somehussar/gui/constraints/NumericConstraintFactory.java new file mode 100644 index 000000000..bd0a024e6 --- /dev/null +++ b/src/main/java/somehussar/gui/constraints/NumericConstraintFactory.java @@ -0,0 +1,13 @@ +package somehussar.gui.constraints; + +import somehussar.gui.FieldConstraint; + +import java.lang.reflect.Field; + +public class NumericConstraintFactory implements ConstraintFactory { + + @Override + public FieldConstraint create(Field field, NumericConstraint ann) { + return new NumericRangeConstraint(ann.min(), ann.max()); + } +} diff --git a/src/main/java/somehussar/gui/constraints/NumericRangeConstraint.java b/src/main/java/somehussar/gui/constraints/NumericRangeConstraint.java new file mode 100644 index 000000000..16bf95895 --- /dev/null +++ b/src/main/java/somehussar/gui/constraints/NumericRangeConstraint.java @@ -0,0 +1,35 @@ +package somehussar.gui.constraints; + +import somehussar.gui.FieldConstraint; + +public final class NumericRangeConstraint implements FieldConstraint { + + private final double min; + private final double max; + + public NumericRangeConstraint(double min, double max) { + this.min = min; + this.max = max; + } + + @Override + public void validate(Object value) { + if (!(value instanceof Number)) { + throw new ConstraintViolationException("Expected number, got " + value); + } + + double v = ((Number) value).doubleValue(); + if (v < min || v > max) { + throw new ConstraintViolationException( + "Value " + v + " must be between " + min + " and " + max + ); + } + } + + public static final class ConstraintViolationException extends RuntimeException { + public ConstraintViolationException(String message) { + super(message); + } + } + +} diff --git a/src/main/java/somehussar/gui/field/EditableField.java b/src/main/java/somehussar/gui/field/EditableField.java new file mode 100644 index 000000000..a1a076efd --- /dev/null +++ b/src/main/java/somehussar/gui/field/EditableField.java @@ -0,0 +1,71 @@ +package somehussar.gui.field; + +import somehussar.gui.FieldConstraints; + +import java.lang.invoke.MethodHandle; + +public final class EditableField { + + private final String name; + private final Class type; + private final MethodHandle getter; + private final MethodHandle setter; + private final FieldConstraints constraints; + + public String getName() { + return name; + } + + public Class getType() { + return type; + } + + @SuppressWarnings({"unchecked"}) + public T read(T instance) { + try { + return (T) getter.invoke(instance); + } catch (Throwable t) { + throw new RuntimeException("Failed to read field " + name, t); + } + } + + public void write(T instance, T value) { + constraints.validate(value); + try { + setter.invoke(instance, value); + } catch (Throwable t) { + throw new RuntimeException("Failed to write field " + name, t); + } + } + + public FieldConstraints getConstraints() { + return this.constraints; + } + + EditableField(Builder b) { + this.name = b.name; + this.type = b.type; + this.getter = b.getter; + this.setter = b.setter; + this.constraints = b.constraints; + } + + public static class Builder { + private String name; + private Class type; + private MethodHandle getter; + private MethodHandle setter; + private FieldConstraints constraints; + + public Builder name(String v) { this.name = v; return this; } + public Builder type(Class v) { this.type = v; return this; } + public Builder getter(MethodHandle v) { this.getter = v; return this; } + public Builder setter(MethodHandle v) { this.setter = v; return this; } + public Builder constraints(FieldConstraints v) { this.constraints = v; return this; } + + public EditableField build() { + return new EditableField(this); + } + + } +}