Field handler for automatic GUI creation

This commit is contained in:
somehussar committed 2026-01-31 04:58:27 +01:00
1 parent 601b0d721e
commit 5182a3474e
9 files changed
+315

No files matched your search

@@ -0,0 +1,5 @@
package somehussar.gui;
public interface FieldConstraint {
void validate(Object value);
}
@@ -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;
}
}
@@ -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 "";
}
}
@@ -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<Class<?>, ClassMetadata> CACHE = new HashMap<>();
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
public static final Map<Class<? extends Annotation>, 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<EditableField> 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<FieldConstraint> 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;
}
}
}
@@ -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<A extends Annotation> {
FieldConstraint create(Field field, A annotation);
}
@@ -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;
}
@@ -0,0 +1,13 @@
package somehussar.gui.constraints;
import somehussar.gui.FieldConstraint;
import java.lang.reflect.Field;
public class NumericConstraintFactory implements ConstraintFactory<NumericConstraint> {
@Override
public FieldConstraint create(Field field, NumericConstraint ann) {
return new NumericRangeConstraint(ann.min(), ann.max());
}
}
@@ -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);
}
}
}
@@ -0,0 +1,71 @@
package somehussar.gui.field;
import somehussar.gui.FieldConstraints;
import java.lang.invoke.MethodHandle;
public final class EditableField<T> {
private final String name;
private final Class<T> 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<T> 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<T> {
private String name;
private Class<T> type;
private MethodHandle getter;
private MethodHandle setter;
private FieldConstraints constraints;
public Builder<T> name(String v) { this.name = v; return this; }
public Builder<T> type(Class<T> v) { this.type = v; return this; }
public Builder<T> getter(MethodHandle v) { this.getter = v; return this; }
public Builder<T> setter(MethodHandle v) { this.setter = v; return this; }
public Builder<T> constraints(FieldConstraints v) { this.constraints = v; return this; }
public EditableField<T> build() {
return new EditableField(this);
}
}
}