From 467ac5d93bdc56861ed6b60edbb70707dee8bd9a Mon Sep 17 00:00:00 2001 From: Tellinq <59412384+Tellinq@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:48:19 -0400 Subject: [PATCH 1/2] Add Range Number (no slider), and Range Slider/Number lists --- .../oneconfig/test/TestConfig_Test.java | 30 ++ modules/config-impl/api/config-impl.api | 69 ++++ .../config/v1/annotations/RangeNumber.java | 68 ++++ .../v1/annotations/RangeNumberList.java | 76 +++++ .../v1/annotations/RangeSliderList.java | 76 +++++ .../v1/collect/impl/OneConfigCollector.java | 46 +++ .../oneconfig/api/config/v1/KtConfig.kt | 89 +++++ .../oneconfig/api/config/v1/Visualizer.kt | 23 ++ .../oneconfig/api/config/v1/dsl/ConfigDSL.kt | 18 +- .../api/config/v1/RangeOptionsTest.java | 150 +++++++++ .../api/config/v1/KtApiParityTest.kt | 20 ++ .../ui/api/settings/BuiltinVisualizers.kt | 14 +- .../internal/ui/api/settings/OptionData.kt | 79 ++++- .../components/settings/NumberListOption.kt | 9 +- .../ui/components/settings/NumberOption.kt | 22 +- .../ui/components/settings/RangeListOption.kt | 82 +++++ .../ui/components/settings/RangeOption.kt | 318 ++++++++++++++++++ .../components/settings/RangeSliderOption.kt | 232 ------------- .../ui/api/settings/RangeOptionDataTest.java | 60 ++++ 19 files changed, 1221 insertions(+), 260 deletions(-) create mode 100644 modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumber.java create mode 100644 modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumberList.java create mode 100644 modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeSliderList.java create mode 100644 modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/RangeOptionsTest.java create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt delete mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeSliderOption.kt create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/settings/RangeOptionDataTest.java diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java b/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java index 54e5d84f2..7f2efd06d 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java @@ -259,6 +259,36 @@ public class TestConfig_Test extends Config { ) public static float[] range = {25f, 75f}; + @RangeNumber( + title = "Range Number", + description = "Two inputs, no track, for spans too wide to drag.", + min = 0f, + max = 1000000f + ) + public static int[] rangeNumber = {1200, 36000}; + + @RangeSliderList( + title = "Range Slider List", + description = "A two-handle slider per entry, stored flat as start, end pairs.", + category = "Lists", + subcategory = "Other Lists", + step = 5f, + addText = "Add range" + ) + public static float[] rangeSliderList = {0f, 25f, 50f, 75f}; + + @RangeNumberList( + title = "Range Number List", + description = "A pair of inputs per entry.", + category = "Lists", + subcategory = "Other Lists", + min = 0f, + max = 72000f, + maxEntries = 4, + addText = "Add range" + ) + public static int[] rangeNumberList = {0, 600, 1200, 9600}; + // everything below is disabled rather than hidden while the master switch is off so that // disabled options can be checked for being non-interactable @Switch( diff --git a/modules/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index 6536c84ff..2d9863edd 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -206,10 +206,16 @@ public class org/polyfrost/oneconfig/api/config/v1/KtConfig : org/polyfrost/onec protected final synthetic fun radiobutton ([Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; public static synthetic fun radiobutton$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/properties/PropertyDelegateProvider; public static synthetic fun radiobutton$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;[Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + protected final synthetic fun rangeNumber (Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + public static synthetic fun rangeNumber$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + protected final synthetic fun rangeNumberList (Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + public static synthetic fun rangeNumberList$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; protected final synthetic fun rangeSlider (Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;FFF)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; protected final synthetic fun rangeSlider (Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; public static synthetic fun rangeSlider$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;FFFILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; public static synthetic fun rangeSlider$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + protected final synthetic fun rangeSliderList (Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + public static synthetic fun rangeSliderList$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[FLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;FFFLorg/polyfrost/oneconfig/api/config/v1/ValueFormatter;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; protected final synthetic fun singleSelectDropdown (Ljava/lang/String;[Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; public static synthetic fun singleSelectDropdown$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; protected final synthetic fun slider (FFFLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; @@ -368,6 +374,24 @@ public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$RadioVisuali public fun visualize (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)V } +public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$RangeNumberListVisualizer : org/polyfrost/oneconfig/api/config/v1/Visualizer { + public static final field $stable I + public fun ()V + public fun visualize (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)V +} + +public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$RangeNumberVisualizer : org/polyfrost/oneconfig/api/config/v1/Visualizer { + public static final field $stable I + public fun ()V + public fun visualize (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)V +} + +public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$RangeSliderListVisualizer : org/polyfrost/oneconfig/api/config/v1/Visualizer { + public static final field $stable I + public fun ()V + public fun visualize (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)V +} + public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$RangeSliderVisualizer : org/polyfrost/oneconfig/api/config/v1/Visualizer { public static final field $stable I public fun ()V @@ -721,6 +745,33 @@ public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1 public abstract fun titleTranslation ()Z } +public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumber : java/lang/annotation/Annotation { + public abstract fun category ()Ljava/lang/String; + public abstract fun description ()Ljava/lang/String; + public abstract fun formatter ()Ljava/lang/Class; + public abstract fun icon ()Ljava/lang/String; + public abstract fun max ()F + public abstract fun min ()F + public abstract fun step ()F + public abstract fun subcategory ()Ljava/lang/String; + public abstract fun title ()Ljava/lang/String; +} + +public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumberList : java/lang/annotation/Annotation { + public abstract fun addText ()Ljava/lang/String; + public abstract fun category ()Ljava/lang/String; + public abstract fun description ()Ljava/lang/String; + public abstract fun formatter ()Ljava/lang/Class; + public abstract fun icon ()Ljava/lang/String; + public abstract fun max ()F + public abstract fun maxEntries ()I + public abstract fun min ()F + public abstract fun reorderable ()Z + public abstract fun step ()F + public abstract fun subcategory ()Ljava/lang/String; + public abstract fun title ()Ljava/lang/String; +} + public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/RangeSlider : java/lang/annotation/Annotation { public abstract fun category ()Ljava/lang/String; public abstract fun description ()Ljava/lang/String; @@ -733,6 +784,21 @@ public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1 public abstract fun title ()Ljava/lang/String; } +public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/RangeSliderList : java/lang/annotation/Annotation { + public abstract fun addText ()Ljava/lang/String; + public abstract fun category ()Ljava/lang/String; + public abstract fun description ()Ljava/lang/String; + public abstract fun formatter ()Ljava/lang/Class; + public abstract fun icon ()Ljava/lang/String; + public abstract fun max ()F + public abstract fun maxEntries ()I + public abstract fun min ()F + public abstract fun reorderable ()Z + public abstract fun step ()F + public abstract fun subcategory ()Ljava/lang/String; + public abstract fun title ()Ljava/lang/String; +} + public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/Slider : java/lang/annotation/Annotation { public abstract fun category ()Ljava/lang/String; public abstract fun categoryKey ()Ljava/lang/String; @@ -906,7 +972,10 @@ public final class org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL { public final fun put (Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$Prop;Lorg/polyfrost/oneconfig/api/config/v1/Visualizer;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$Prop; public final fun radiobutton (I[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$OptionsProp; public final synthetic fun radiobutton (I[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$Prop; + public final fun rangeNumber (FF)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$RangeProp; + public final fun rangeNumberList ([F)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$NumberListProp; public final fun rangeSlider (FF)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$RangeProp; + public final fun rangeSliderList ([F)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$NumberListProp; public final fun singleSelectDropdown (I[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$OptionsProp; public final fun slider (F)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$FloatProp; public final fun slider (I)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$IntProp; diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumber.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumber.java new file mode 100644 index 000000000..e0f92201a --- /dev/null +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumber.java @@ -0,0 +1,68 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1.annotations; + +import java.lang.annotation.*; +import org.polyfrost.oneconfig.api.config.v1.ValueFormatter; +import org.polyfrost.oneconfig.api.config.v1.Visualizer; + +/** + * A start and end pair within {@link #min()} and {@link #max()} edited with two number inputs + *
+ * This is {@link RangeSlider} without the track so it suits ranges too wide to drag across + *
+ * The annotated field holds both ends together as a two-element numeric array or list + * such as {@code float[]} or {@code int[]} or {@code List} where index 0 is the + * start and index 1 is the end + *
+ * Values are written back in whatever container shape the field already holds so its type is preserved + */ +@Option(display = Visualizer.RangeNumberVisualizer.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +public @interface RangeNumber { + String title(); + + String description() default ""; + + String icon() default ""; + + String category() default "General"; + + String subcategory() default "General"; + + float min() default 0f; + + float max() default 100f; + + /** Amount the arrows on each input step by */ + float step() default 1f; + + Class formatter() default ValueFormatter.class; + +} diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumberList.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumberList.java new file mode 100644 index 000000000..e969286a1 --- /dev/null +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeNumberList.java @@ -0,0 +1,76 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1.annotations; + +import java.lang.annotation.*; +import org.polyfrost.oneconfig.api.config.v1.ValueFormatter; +import org.polyfrost.oneconfig.api.config.v1.Visualizer; + +/** + * Renders a user-editable list of ranges with each row being a pair of number inputs + *

+ * The annotated field must be a flat numeric array such as {@code int[]} or {@code float[]} or a {@code List} + * of numbers holding each range's start then end so {@code {0, 10, 20, 30}} is the ranges 0-10 and 20-30 + *

+ * The field must hold an even number of values + *

+ * See {@link RangeSliderList} for the slider variant + */ +@Option(display = Visualizer.RangeNumberListVisualizer.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +public @interface RangeNumberList { + String title(); + + String description() default ""; + + String icon() default ""; + + String category() default "General"; + + String subcategory() default "General"; + + float min() default 0f; + + float max() default 100f; + + /** Amount the arrows on each input step by */ + float step() default 1f; + + /** Maximum number of ranges the user may add and {@code 0} means unlimited */ + int maxEntries() default 0; + + /** Allow the user to drag ranges to reorder them */ + boolean reorderable() default true; + + /** Label of the button that appends a new range */ + String addText() default "Add"; + + Class formatter() default ValueFormatter.class; + +} diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeSliderList.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeSliderList.java new file mode 100644 index 000000000..411218d0e --- /dev/null +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/RangeSliderList.java @@ -0,0 +1,76 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1.annotations; + +import java.lang.annotation.*; +import org.polyfrost.oneconfig.api.config.v1.ValueFormatter; +import org.polyfrost.oneconfig.api.config.v1.Visualizer; + +/** + * Renders a user-editable list of ranges with each row being a two-handle slider with a value field for each end + *

+ * The annotated field must be a flat numeric array such as {@code int[]} or {@code float[]} or a {@code List} + * of numbers holding each range's start then end so {@code {0, 10, 20, 30}} is the ranges 0-10 and 20-30 + *

+ * The field must hold an even number of values + *

+ * See {@link RangeNumberList} for the variant without the track + */ +@Option(display = Visualizer.RangeSliderListVisualizer.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +public @interface RangeSliderList { + String title(); + + String description() default ""; + + String icon() default ""; + + String category() default "General"; + + String subcategory() default "General"; + + float min() default 0f; + + float max() default 100f; + + /** Value the handles snap to multiples of and {@code 0} slides continuously */ + float step() default 1f; + + /** Maximum number of ranges the user may add and {@code 0} means unlimited */ + int maxEntries() default 0; + + /** Allow the user to drag ranges to reorder them */ + boolean reorderable() default true; + + /** Label of the button that appends a new range */ + String addText() default "Add"; + + Class formatter() default ValueFormatter.class; + +} diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java index a772ea36f..164fc5e27 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java @@ -28,10 +28,12 @@ import java.lang.annotation.Annotation; import java.lang.invoke.MethodHandle; +import java.lang.reflect.Array; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.util.Collection; import java.util.HashMap; import java.util.List; import kotlin.Unit; @@ -201,7 +203,9 @@ protected void handleMetadata(Tree tree, @NotNull Property property, @NotNull throw new IllegalArgumentException(String.format("@RangeSlider field '%s' has step (%s) larger than its range (%s to %s, range=%s). The slider will not function correctly.", field.getName(), slider.step(), slider.min(), slider.max(), range)); } + validateRangeField(field, property, "@RangeSlider"); } + case RangeNumber ignored -> validateRangeField(field, property, "@RangeNumber"); case SliderList slider -> { float range = slider.max() - slider.min(); if (slider.step() > 0f && slider.step() > range) { @@ -209,6 +213,15 @@ protected void handleMetadata(Tree tree, @NotNull Property property, @NotNull field.getName(), slider.step(), slider.min(), slider.max(), range)); } } + case RangeSliderList slider -> { + float range = slider.max() - slider.min(); + if (slider.step() > 0f && slider.step() > range) { + throw new IllegalArgumentException(String.format("@RangeSliderList field '%s' has step (%s) larger than its range (%s to %s, range=%s). The sliders will not function correctly.", + field.getName(), slider.step(), slider.min(), slider.max(), range)); + } + validateRangeListField(field, property, "@RangeSliderList"); + } + case RangeNumberList ignored -> validateRangeListField(field, property, "@RangeNumberList"); case Color color -> { if (!color.alpha()) property.addMetadata("noAlpha", Unit.INSTANCE); } @@ -230,6 +243,39 @@ protected void handleMetadata(Tree tree, @NotNull Property property, @NotNull } } + /** A single range needs exactly a start and end, anything else shows min to max and the first write resizes it */ + private void validateRangeField(Field field, Property property, String annotationName) { + int size = rangeFieldSize(field, property, annotationName); + if (size >= 0 && size != 2) { + throw new IllegalArgumentException(String.format( + "%s field '%s' holds %d values but needs exactly a start and an end.", + annotationName, field.getName(), size + )); + } + } + + private void validateRangeListField(Field field, Property property, String annotationName) { + int size = rangeFieldSize(field, property, annotationName); + if (size >= 0 && size % 2 != 0) { + throw new IllegalArgumentException(String.format( + "%s field '%s' holds %d values but needs a start and end for every range, so an even count.", + annotationName, field.getName(), size + )); + } + } + + /** The number of values a range field holds or -1 while it is null */ + private int rangeFieldSize(Field field, Property property, String annotationName) { + Object value = property.get(); + if (value == null) return -1; + if (value.getClass().isArray()) return Array.getLength(value); + if (value instanceof Collection c) return c.size(); + throw new IllegalArgumentException(String.format( + "%s field '%s' must be a numeric array or List, but was %s.", + annotationName, field.getName(), field.getGenericType().getTypeName() + )); + } + private void validateItemListField(Field field, ItemList annotation) { Class type = field.getType(); boolean stringArray = type.isArray() && type.getComponentType() == String.class; diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt index 5fd9812f6..43de56534 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt @@ -727,6 +727,32 @@ open class KtConfig(id: String, title: String, category: Category, icon: String? step: Float = 1f, ) = rangeSlider(name, def, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey, min, max, step, null) + /** Two number inputs holding `[start, end]`, a [rangeSlider] without the track */ + @JvmSynthetic + protected fun rangeNumber( + name: String, + def: FloatArray, + nameKey: String? = null, + description: String? = null, + descriptionKey: String? = null, + icon: String? = null, + category: String? = "General", + categoryKey: String? = null, + subcategory: String? = "General", + subcategoryKey: String? = null, + min: Float = 0f, + max: Float = 100f, + step: Float = 1f, + formatter: ValueFormatter? = null, + ) = Provider(def, name, description, category, subcategory, FloatArray::class.java, Visualizer.RangeNumberVisualizer()) { + addMetadata(name, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey) + require(def.size == 2) { "rangeNumber '$name' needs a start and end, got ${def.size} values" } + this.addMetadata("min", min) + this.addMetadata("max", max) + this.addMetadata("step", step) + this.addMetadata("formatter", formatter) + } + /** A file or folder picker holding the chosen path, [types] are extensions such as `.png` */ @JvmSynthetic protected fun file( @@ -884,6 +910,69 @@ open class KtConfig(id: String, title: String, category: Category, icon: String? step: Float = 1f, ) = sliderList(name, def, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey, maxEntries, reorderable, addText, addTextKey, min, max, step, null) + /** A list of two-handle sliders, [def] holds each range's start then end so `[0, 10, 20, 30]` is 0-10 and 20-30 */ + @JvmSynthetic + protected fun rangeSliderList( + name: String, + def: FloatArray = FloatArray(0), + nameKey: String? = null, + description: String? = null, + descriptionKey: String? = null, + icon: String? = null, + category: String? = "General", + categoryKey: String? = null, + subcategory: String? = "General", + subcategoryKey: String? = null, + maxEntries: Int = 0, + reorderable: Boolean = true, + addText: String? = null, + addTextKey: String? = null, + min: Float = 0f, + max: Float = 100f, + step: Float = 1f, + formatter: ValueFormatter? = null, + ) = Provider(def, name, description, category, subcategory, FloatArray::class.java, Visualizer.RangeSliderListVisualizer()) { + addMetadata(name, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey) + addListMetadata(maxEntries, reorderable, addText, addTextKey) + require(def.size % 2 == 0) { "rangeSliderList '$name' needs a start and end for every range, got ${def.size} values" } + requireStep(name, min, max, step) + this.addMetadata("min", min) + this.addMetadata("max", max) + this.addMetadata("step", step) + this.addMetadata("formatter", formatter) + } + + /** A list of [rangeNumber] rows stored the same way as [rangeSliderList] */ + @JvmSynthetic + protected fun rangeNumberList( + name: String, + def: FloatArray = FloatArray(0), + nameKey: String? = null, + description: String? = null, + descriptionKey: String? = null, + icon: String? = null, + category: String? = "General", + categoryKey: String? = null, + subcategory: String? = "General", + subcategoryKey: String? = null, + maxEntries: Int = 0, + reorderable: Boolean = true, + addText: String? = null, + addTextKey: String? = null, + min: Float = 0f, + max: Float = 100f, + step: Float = 1f, + formatter: ValueFormatter? = null, + ) = Provider(def, name, description, category, subcategory, FloatArray::class.java, Visualizer.RangeNumberListVisualizer()) { + addMetadata(name, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey) + addListMetadata(maxEntries, reorderable, addText, addTextKey) + require(def.size % 2 == 0) { "rangeNumberList '$name' needs a start and end for every range, got ${def.size} values" } + this.addMetadata("min", min) + this.addMetadata("max", max) + this.addMetadata("step", step) + this.addMetadata("formatter", formatter) + } + /** A list of ARGB colours */ @JvmSynthetic protected fun colorList( diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt index 02c141fc5..332bd40d1 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt @@ -97,6 +97,29 @@ fun interface Visualizer { @Composable override fun visualize(prop: Property<*>) { Visualizer[RangeSliderVisualizer::class.java]?.visualize(prop) } } + /** + * A start and end pair edited with two number inputs and no track, stored the same way as [RangeSliderVisualizer] + */ + class RangeNumberVisualizer : Visualizer { + @Composable override fun visualize(prop: Property<*>) { Visualizer[RangeNumberVisualizer::class.java]?.visualize(prop) } + } + + /** + * A user-editable list of [RangeSliderVisualizer] rows held in one property as a flat numeric array or list + * + * Each range takes two consecutive values so `[0, 10, 20, 30]` holds the ranges 0-10 and 20-30 + */ + class RangeSliderListVisualizer : Visualizer { + @Composable override fun visualize(prop: Property<*>) { Visualizer[RangeSliderListVisualizer::class.java]?.visualize(prop) } + } + + /** + * A user-editable list of [RangeNumberVisualizer] rows stored the same way as [RangeSliderListVisualizer] + */ + class RangeNumberListVisualizer : Visualizer { + @Composable override fun visualize(prop: Property<*>) { Visualizer[RangeNumberListVisualizer::class.java]?.visualize(prop) } + } + /** * A slider with a third "inherited" state for overrides that fall back to a broader setting when unset * diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt index bf43bba63..e00211a0b 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt @@ -73,6 +73,9 @@ class ConfigDSL(id: String? = null, title: String? = null, description: String? /** Holds `[start, end]` */ fun rangeSlider(start: Float, end: Float): RangeProp = put(RangeProp(floatArrayOf(start, end)), Visualizer.RangeSliderVisualizer()) + /** Holds `[start, end]`, a [rangeSlider] without the track */ + fun rangeNumber(start: Float, end: Float): RangeProp = put(RangeProp(floatArrayOf(start, end)), Visualizer.RangeNumberVisualizer()) + fun file(default: String = ""): FileProp = put(FileProp(default), Visualizer.FileVisualizer()) fun textList(vararg default: String): TextListProp = put(TextListProp(arrayOf(*default)), Visualizer.TextListVisualizer()) @@ -81,6 +84,18 @@ class ConfigDSL(id: String? = null, title: String? = null, description: String? fun sliderList(vararg default: Float): NumberListProp = put(NumberListProp(default), Visualizer.SliderListVisualizer()) + /** Each range's start then end so `rangeSliderList(0f, 10f, 20f, 30f)` holds 0-10 and 20-30 */ + fun rangeSliderList(vararg default: Float): NumberListProp { + require(default.size % 2 == 0) { "rangeSliderList needs a start and end for every range, got ${default.size} values" } + return put(NumberListProp(default), Visualizer.RangeSliderListVisualizer()) + } + + /** Stored the same way as [rangeSliderList] */ + fun rangeNumberList(vararg default: Float): NumberListProp { + require(default.size % 2 == 0) { "rangeNumberList needs a start and end for every range, got ${default.size} values" } + return put(NumberListProp(default), Visualizer.RangeNumberListVisualizer()) + } + /** ARGB colours */ fun colorList(vararg default: Int): ColorListProp = put(ColorListProp(default), Visualizer.ColorListVisualizer()) @@ -439,7 +454,8 @@ class ConfigDSL(id: String? = null, title: String? = null, description: String? private fun requireStep(key: String, value: Any?) { val vis = this["visualizer"] - if (vis !is Visualizer.SliderVisualizer && vis !is Visualizer.RangeSliderVisualizer && vis !is Visualizer.SliderListVisualizer) return + if (vis !is Visualizer.SliderVisualizer && vis !is Visualizer.RangeSliderVisualizer && + vis !is Visualizer.SliderListVisualizer && vis !is Visualizer.RangeSliderListVisualizer) return fun num(k: String) = ((if (k == key) value else this[k]) as? Number)?.toFloat() val min = num("min") ?: return val max = num("max") ?: return diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/RangeOptionsTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/RangeOptionsTest.java new file mode 100644 index 000000000..2f0983161 --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/RangeOptionsTest.java @@ -0,0 +1,150 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.annotations.RangeNumber; +import org.polyfrost.oneconfig.api.config.v1.annotations.RangeNumberList; +import org.polyfrost.oneconfig.api.config.v1.annotations.RangeSlider; +import org.polyfrost.oneconfig.api.config.v1.annotations.RangeSliderList; +import org.polyfrost.oneconfig.api.config.v1.collect.impl.OneConfigCollector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RangeOptionsTest { + @Test + void collectsRangeMetadata() { + Tree tree = new OneConfigCollector().collect(new ValidRangeConfig()); + + Property number = tree.getProp("number"); + assertEquals(Visualizer.RangeNumberVisualizer.class, number.getMetadata("visualizer")); + assertEquals(1000000f, (Float) number.getMetadata("max")); + + Property sliders = tree.getProp("sliders"); + assertEquals(Visualizer.RangeSliderListVisualizer.class, sliders.getMetadata("visualizer")); + assertEquals(5f, (Float) sliders.getMetadata("step")); + + Property numbers = tree.getProp("numbers"); + assertEquals(Visualizer.RangeNumberListVisualizer.class, numbers.getMetadata("visualizer")); + assertEquals(Integer.valueOf(4), numbers.getMetadata("maxEntries")); + } + + @Test + void rejectsAnOddNumberOfRangeValues() { + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> new OneConfigCollector().collect(new OddRangeListConfig()) + ); + assertTrue(hasMessage(exception, "even count")); + } + + @Test + void rejectsASingleRangeWithoutExactlyTwoValues() { + RuntimeException number = assertThrows( + RuntimeException.class, + () -> new OneConfigCollector().collect(new ShortRangeNumberConfig()) + ); + assertTrue(hasMessage(number, "exactly a start and an end")); + + RuntimeException slider = assertThrows( + RuntimeException.class, + () -> new OneConfigCollector().collect(new LongRangeSliderConfig()) + ); + assertTrue(hasMessage(slider, "exactly a start and an end")); + } + + @Test + void rejectsASliderListStepLargerThanItsRange() { + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> new OneConfigCollector().collect(new BadStepConfig()) + ); + assertTrue(hasMessage(exception, "larger than its range")); + } + + private static boolean hasMessage(Throwable throwable, String text) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(text)) return true; + } + return false; + } + + private static final class ValidRangeConfig extends Config { + @RangeNumber(title = "Number", max = 1000000f) + private int[] number = {10, 20}; + + @RangeSliderList(title = "Sliders", step = 5f) + private float[] sliders = {0f, 25f, 50f, 75f}; + + @RangeNumberList(title = "Numbers", maxEntries = 4) + private List numbers = new ArrayList<>(List.of(1, 2)); + + private ValidRangeConfig() { + super("range-options-test.json", "Range Options Test", Category.OTHER); + } + } + + private static final class OddRangeListConfig extends Config { + @RangeNumberList(title = "Odd") + private int[] odd = {1, 2, 3}; + + private OddRangeListConfig() { + super("odd-range-list-test.json", "Odd Range List Test", Category.OTHER); + } + } + + private static final class ShortRangeNumberConfig extends Config { + @RangeNumber(title = "Short") + private int[] shortRange = {1}; + + private ShortRangeNumberConfig() { + super("short-range-number-test.json", "Short Range Number Test", Category.OTHER); + } + } + + private static final class LongRangeSliderConfig extends Config { + @RangeSlider(title = "Long") + private float[] longRange = {1f, 2f, 3f}; + + private LongRangeSliderConfig() { + super("long-range-slider-test.json", "Long Range Slider Test", Category.OTHER); + } + } + + private static final class BadStepConfig extends Config { + @RangeSliderList(title = "Bad", min = 0f, max = 1f, step = 5f) + private float[] bad = {}; + + private BadStepConfig() { + super("bad-range-step-test.json", "Bad Range Step Test", Category.OTHER); + } + } +} diff --git a/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt b/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt index 4237a3c2d..69ca032a8 100644 --- a/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt +++ b/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt @@ -19,6 +19,8 @@ class KtApiParityTest { val note by info(description = "hello", type = "warning") var multi by multiSelectDropdown("Multi", arrayOf("a", "b", "c")) var range by rangeSlider("Range", floatArrayOf(10f, 20f), step = 5f) + var rangeNum by rangeNumber("Range Number", floatArrayOf(10f, 20f), max = 1000000f) + var ranges by rangeSliderList("Ranges", floatArrayOf(0f, 10f, 20f, 30f), maxEntries = 3) var s by slider("Slider", 5f) var pct by slider("Percent", 5f) { "$it%" } var t by text("Text", regex = "\\d+") @@ -47,6 +49,10 @@ class KtApiParityTest { assertArrayEquals(booleanArrayOf(false, false, false), cfg.multi) assertEquals(true, tree.getProp("multi")!!.getMetadata("checkable")) assertEquals(5f, tree.getProp("range")!!.getMetadata("step")) + assertTrue(tree.getProp("rangeNum")!!.getMetadata("visualizer") is Visualizer.RangeNumberVisualizer) + assertEquals(1000000f, tree.getProp("rangeNum")!!.getMetadata("max")) + assertTrue(tree.getProp("ranges")!!.getMetadata("visualizer") is Visualizer.RangeSliderListVisualizer) + assertEquals(3, tree.getProp("ranges")!!.getMetadata("maxEntries")) assertTrue(tree.getProp("s")!!.getMetadata("visualizer") is Visualizer.SliderVisualizer) assertEquals("\\d+", tree.getProp("t")!!.getMetadata("regex")) assertTrue(tree.getProp("colors")!!.getMetadata("noAlpha") != null) @@ -113,4 +119,18 @@ class KtApiParityTest { val list = dsl.draggableList(arrayOf("a"), "a", "b").apply { checkable = true } assertEquals(true, list["checkable"]) } + + class OddRanges : KtConfig("kt_api_parity_odd_ranges", "Odd", Category.QOL) { + var r by rangeNumberList("Ranges", floatArrayOf(1f, 2f, 3f)) + } + + @Test + fun rangeListsNeedPairs() { + assertThrows(IllegalArgumentException::class.java) { OddRanges() } + val dsl = ConfigDSL("dsl_parity") + assertThrows(IllegalArgumentException::class.java) { dsl.rangeSliderList(1f, 2f, 3f) } + val sliders = dsl.rangeSliderList(0f, 10f).apply { min = 0f; max = 1f } + assertThrows(IllegalArgumentException::class.java) { sliders.step = 5f } + dsl.rangeNumberList(0f, 10f).apply { min = 0f; max = 1f; step = 5f } + } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt index f92e802b2..51510265d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt @@ -18,7 +18,8 @@ import org.polyfrost.oneconfig.internal.ui.components.settings.NumberChainOption import org.polyfrost.oneconfig.internal.ui.components.settings.NumberListOption import org.polyfrost.oneconfig.internal.ui.components.settings.NumberOption import org.polyfrost.oneconfig.internal.ui.components.settings.RadioButtonOption -import org.polyfrost.oneconfig.internal.ui.components.settings.RangeSliderOption +import org.polyfrost.oneconfig.internal.ui.components.settings.RangeListOption +import org.polyfrost.oneconfig.internal.ui.components.settings.RangeOption import org.polyfrost.oneconfig.internal.ui.components.settings.SliderOption import org.polyfrost.oneconfig.internal.ui.components.settings.TextListOption import org.polyfrost.oneconfig.internal.ui.components.settings.TextOption @@ -35,7 +36,10 @@ internal object BuiltinVisualizers { SliderOption(SliderOptionData(prop)) }) Visualizer.register(Visualizer.RangeSliderVisualizer::class.java, Visualizer { prop -> - RangeSliderOption(RangeSliderOptionData(prop)) + RangeOption(RangeOptionData(prop, RangeOptionData.Style.Slider)) + }) + Visualizer.register(Visualizer.RangeNumberVisualizer::class.java, Visualizer { prop -> + RangeOption(RangeOptionData(prop, RangeOptionData.Style.Spinner)) }) Visualizer.register(Visualizer.InheritableSliderVisualizer::class.java, Visualizer { prop -> InheritableSliderOption(InheritableSliderOptionData(prop)) @@ -70,6 +74,12 @@ internal object BuiltinVisualizers { Visualizer.register(Visualizer.SliderListVisualizer::class.java, Visualizer { prop -> NumberListOption(NumberListOptionData(prop, NumberListOptionData.Style.Slider)) }) + Visualizer.register(Visualizer.RangeSliderListVisualizer::class.java, Visualizer { prop -> + RangeListOption(RangeListOptionData(prop, RangeOptionData.Style.Slider)) + }) + Visualizer.register(Visualizer.RangeNumberListVisualizer::class.java, Visualizer { prop -> + RangeListOption(RangeListOptionData(prop, RangeOptionData.Style.Spinner)) + }) Visualizer.register(Visualizer.DropdownVisualizer::class.java, Visualizer { prop -> DropdownOption(DropdownOptionData(prop)) }) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt index b0bda81f7..8c6250445 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt @@ -4,6 +4,7 @@ import java.awt.Color import java.lang.reflect.Array as ReflectArray import java.util.function.Function import java.util.function.Supplier +import kotlin.math.floor import kotlin.math.roundToInt import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.internal.ui.api.shown @@ -44,18 +45,25 @@ class SliderOptionData(prop: Property<*>) : OptionData(prop) { * * Both ends are written together in the container shape the property already holds so the backing field's * type is preserved + * + * [style] picks between a two-handle track with inputs beside it and the inputs alone */ -class RangeSliderOptionData(prop: Property<*>) : OptionData(prop) { +class RangeOptionData(prop: Property<*>, val style: Style) : OptionData(prop) { + enum class Style { Slider, Spinner } + val min: Float get() = prop.getMetadata("min") ?: 0f val max: Float get() = prop.getMetadata("max") ?: 100f val step: Float get() = prop.getMetadata("step") ?: 0f val format: ((Float) -> String)? get() = prop.valueFormat(prop.type.componentType ?: Float::class.java) - /** The current `start to end` or null when the property does not hold a two-element numeric pair */ + /** + * The current `start to end` held within [min] and [max] or null when the property does not hold a + * two-element numeric pair + */ fun read(): Pair? { val values = prop.readNumbers() ?: return null if (values.size < 2) return null - return values[0] to values[1] + return values[0].withinBounds(min, max) to values[1].withinBounds(min, max) } fun write(start: Float, end: Float) = prop.writeNumbers(listOf(start, end)) @@ -117,16 +125,16 @@ private fun Property<*>.writeNumbers(values: List) { is FloatArray -> values.toFloatArray() is DoubleArray -> values.map { it.toDouble() }.toDoubleArray() is IntArray -> values.map { it.roundToInt() }.toIntArray() - is LongArray -> values.map { it.toLong() }.toLongArray() + is LongArray -> values.map { it.roundedTo(java.lang.Long.TYPE) as Long }.toLongArray() is Array<*> -> { val component = current.javaClass.componentType ReflectArray.newInstance(component, values.size).also { array -> - values.forEachIndexed { index, value -> ReflectArray.set(array, index, value.toNumberType(component)) } + values.forEachIndexed { index, value -> ReflectArray.set(array, index, value.roundedTo(component)) } } } is List<*> -> { val component = (current.firstOrNull() ?: 0f).javaClass - values.map { it.toNumberType(component) } + values.map { it.roundedTo(component) } } else -> return } @@ -275,20 +283,63 @@ class NumberListOptionData(prop: Property<*>, val style: Style) : ListOptionData val max: Float get() = prop.getMetadata("max") ?: 100f val step: Float get() = prop.getMetadata("step") ?: 0f - val elementType: Class<*> - get() { - if (prop.type.isArray) return prop.type.componentType - elements().firstOrNull { it is Number }?.let { return it.javaClass } - val whole = min == min.toInt().toFloat() && max == max.toInt().toFloat() && - step == step.toInt().toFloat() - return if (whole) Integer::class.java else java.lang.Double::class.java - } + val elementType: Class<*> get() = numericElementType(min, max, step) val format: ((Float) -> String)? get() = prop.valueFormat(elementType) fun floats(): List = elements().map { (it as? Number)?.toFloat() ?: min } } +/** + * A user-editable list of start/end pairs held flat in a single numeric array or list + * + * Each range takes two consecutive values so `[0, 10, 20, 30]` reads as 0-10 and 20-30 + */ +class RangeListOptionData(prop: Property<*>, val style: RangeOptionData.Style) : ListOptionData(prop) { + val min: Float get() = prop.getMetadata("min") ?: 0f + val max: Float get() = prop.getMetadata("max") ?: 100f + val step: Float get() = prop.getMetadata("step") ?: 0f + + val elementType: Class<*> get() = numericElementType(min, max, step) + + val format: ((Float) -> String)? get() = prop.valueFormat(elementType) + + /** Pairs up the stored values held within [min] and [max] ending a trailing start that has no end at [max] */ + fun ranges(): List> { + val min = min + val max = max + return elements().map { ((it as? Number)?.toFloat() ?: min).withinBounds(min, max) } + .chunked(2) { it[0] to it.getOrElse(1) { max } } + } + + fun setRanges(ranges: List>) { + val type = elementType + setElements(ranges.flatMap { (start, end) -> listOf(start.roundedTo(type), end.roundedTo(type)) }) + } +} + +/** Clamps without throwing when a misconfigured option has [min] above [max] */ +private fun Float.withinBounds(min: Float, max: Float): Float = coerceAtLeast(min).coerceAtMost(max) + +/** Converts to [type] rounding half up for whole number types the same as [roundToInt] does */ +private fun Float.roundedTo(type: Class<*>): Number { + val whole = when (type) { + Integer.TYPE, Integer::class.java, java.lang.Long.TYPE, java.lang.Long::class.java, + java.lang.Short.TYPE, java.lang.Short::class.java, java.lang.Byte.TYPE, java.lang.Byte::class.java -> true + else -> false + } + return (if (whole) floor(this + 0.5f) else this).toNumberType(type) +} + +/** The element type of a numeric list falling back to a guess from the bounds while it is an empty list */ +private fun ListOptionData.numericElementType(min: Float, max: Float, step: Float): Class<*> { + if (prop.type.isArray) return prop.type.componentType + elements().firstOrNull { it is Number }?.let { return it.javaClass } + val whole = min == min.toInt().toFloat() && max == max.toInt().toFloat() && + step == step.toInt().toFloat() + return if (whole) Integer::class.java else java.lang.Double::class.java +} + class DropdownOptionData(prop: Property<*>) : OptionData(prop) { val options: List? get() = prop.optionLabels() val optionValues: List? get() = prop.optionValues() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt index 2ab813ec5..41da0b011 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import kotlin.math.roundToInt import org.polyfrost.oneconfig.internal.ui.api.settings.NumberListOptionData @Composable @@ -16,13 +15,7 @@ fun NumberListOption(data: NumberListOptionData) { data.setElements(values.map { it.toNumberType(elementType) }) } - val spinnerStep = remember(data.step, data.min, data.max) { - when { - data.step > 0f -> data.step - data.min == data.min.roundToInt().toFloat() && data.max == data.max.roundToInt().toFloat() -> 1f - else -> 0.1f - } - } + val spinnerStep = remember(data.step, data.min, data.max) { spinnerStep(data.step, data.min, data.max) } ListOptionContainer( state = state, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt index 0b6b0a29c..87f650230 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt @@ -62,6 +62,13 @@ fun filterNumberInput(input: String): String { return sb.toString() } +/** How far a spinner's arrows move when the option has no step of its own, whole bounds step by 1 and others by 0.1 */ +fun spinnerStep(step: Float, min: Float, max: Float): Float = when { + step > 0f -> step + min == min.roundToInt().toFloat() && max == max.roundToInt().toFloat() -> 1f + else -> 0.1f +} + fun formatSpinnerValue(v: Float): String = if (v == v.toLong().toFloat()) v.toLong().toString() else String.format(Locale.ROOT, "%.2f", v) @@ -106,14 +113,20 @@ fun NumberSpinner( step: Float, width: Dp = 87.dp, format: ((Float) -> String)? = null, + arrowMin: Float = min, + arrowMax: Float = max, + onEditEnd: (() -> Unit)? = null, ) { val theme = LocalTheme.current var focused by remember { mutableStateOf(false) } val formatted = if (format != null && !focused) format(value) else null var text by remember(value) { mutableStateOf(formatSpinnerValue(value)) } + val arrowLo = maxOf(min, arrowMin) + val arrowHi = maxOf(arrowLo, minOf(max, arrowMax)) + fun commit(v: Float) { - val clamped = v.coerceIn(min, max) + val clamped = v.coerceIn(arrowLo, arrowHi) text = formatSpinnerValue(clamped) onValueChange(clamped) } @@ -150,12 +163,15 @@ fun NumberSpinner( } }, modifier = Modifier.trackTextInputFocus() - .onFocusChanged { focused = it.isFocused } + .onFocusChanged { + if (focused && !it.isFocused) onEditEnd?.invoke() + focused = it.isFocused + } .weight(1f) .padding(start = 8.dp, top = 5.dp, bottom = 5.dp), ) - SpinnerArrows(value, min, max, step) { commit(it) } + SpinnerArrows(value, arrowLo, arrowHi, step) { commit(it) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt new file mode 100644 index 000000000..eea15ac50 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt @@ -0,0 +1,82 @@ +package org.polyfrost.oneconfig.internal.ui.components.settings + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.internal.ui.api.settings.RangeListOptionData +import org.polyfrost.oneconfig.internal.ui.api.settings.RangeOptionData + +/** + * A user-editable list of ranges where each row is a [RangeOption] + * + * Rows keep whatever order their ends were typed in until the input loses focus but every write stores + * each pair with start at or below end + */ +@Composable +fun RangeListOption(data: RangeListOptionData) { + val state = rememberListEntries(data.prop, data.ranges()) { ranges -> + data.setRanges(ranges.map { (start, end) -> minOf(start, end) to maxOf(start, end) }) + } + + val spinnerStep = remember(data.step, data.min, data.max) { spinnerStep(data.step, data.min, data.max) } + + ListOptionContainer( + state = state, + reorderable = data.reorderable, + maxEntries = data.maxEntries, + addText = data.addText, + onAdd = { state.add(data.min to data.max) }, + ) { entry, _ -> + val (start, end) = entry.value + RangeEntry( + data = data, + start = start, + end = end, + step = spinnerStep, + onValueChange = { newStart, newEnd -> state.update(entry.id, newStart to newEnd) }, + ) + } +} + +@Composable +private fun RowScope.RangeEntry( + data: RangeListOptionData, + start: Float, + end: Float, + step: Float, + onValueChange: (start: Float, end: Float) -> Unit, +) { + if (data.style == RangeOptionData.Style.Slider) { + RangeSliderTrack( + start = start, + end = end, + onValueChange = onValueChange, + min = data.min, + max = data.max, + step = data.step, + modifier = Modifier.weight(1f), + thumbWidth = 11.dp, + thumbHeight = 14.dp, + trackHeight = 4.dp, + ) + RangeSpinners(start, end, onValueChange, data.min, data.max, step, width = 56.dp, format = data.format) + } else { + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val spinnerWidth = (maxWidth - RangeSeparatorWidth) / 2 + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + RangeSpinners( + start, end, onValueChange, data.min, data.max, step, + width = spinnerWidth, + format = data.format, + separated = true, + ) + } + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt new file mode 100644 index 000000000..76d95f484 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt @@ -0,0 +1,318 @@ +package org.polyfrost.oneconfig.internal.ui.components.settings + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.abs +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.internal.ui.api.settings.RangeOptionData +import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent +import org.polyfrost.oneconfig.internal.ui.sound.UiSounds +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +/** + * A start and end pair shown either as a two-thumb slider with an input per end or as the inputs alone + * + * Whatever is shown the stored pair always has start at or below end + */ +@Composable +fun RangeOption(data: RangeOptionData) { + val initial = data.read() + var start by remember(data.prop) { mutableStateOf(initial?.first ?: data.min) } + var end by remember(data.prop) { mutableStateOf(initial?.second ?: data.max) } + + fun update(newStart: Float, newEnd: Float) { + start = newStart + end = newEnd + data.write(minOf(newStart, newEnd), maxOf(newStart, newEnd)) + } + + val step = spinnerStep(data.step, data.min, data.max) + + when (data.style) { + RangeOptionData.Style.Slider -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + RangeSliderTrack( + start = start, + end = end, + onValueChange = ::update, + min = data.min, + max = data.max, + step = data.step, + modifier = Modifier + // the spinners beside the track outgrow narrow columns like the HUD editor's + .weight(1f, fill = false) + .width(LocalOptionWidth.current), + ) + RangeSpinners(start, end, ::update, data.min, data.max, step, width = 80.dp, format = data.format) + } + RangeOptionData.Style.Spinner -> Row( + modifier = Modifier.width(LocalOptionWidth.current), + verticalAlignment = Alignment.CenterVertically, + ) { + RangeSpinners( + start, end, ::update, data.min, data.max, step, + width = (LocalOptionWidth.current - RangeSeparatorWidth) / 2, + format = data.format, + separated = true, + ) + } + } +} + +internal val RangeSeparatorWidth = 20.dp + +/** + * An input for each end of a range laid out in the caller's row + * + * Typing is free to pass the other end so a number is never clamped midway through being typed and the pair + * is put back in order once the input loses focus while the arrows stop at the other end + * + * [separated] puts a dash between the inputs for rows that have no track to read them against + */ +@Composable +internal fun RangeSpinners( + start: Float, + end: Float, + onValueChange: (start: Float, end: Float) -> Unit, + min: Float, + max: Float, + step: Float, + width: Dp, + format: ((Float) -> String)?, + separated: Boolean = false, +) { + val reorder = { if (start > end) onValueChange(end, start) } + NumberSpinner( + value = start, + onValueChange = { onValueChange(it, end) }, + min = min, + max = max, + step = step, + width = width, + format = format, + arrowMax = end, + onEditEnd = reorder, + ) + if (separated) { + Box(Modifier.width(RangeSeparatorWidth), contentAlignment = Alignment.Center) { + Text("-", color = LocalTheme.current.textColorSecondary, fontSize = 13.sp) + } + } + NumberSpinner( + value = end, + onValueChange = { onValueChange(start, it) }, + min = min, + max = max, + step = step, + width = width, + format = format, + arrowMin = start, + onEditEnd = reorder, + ) +} + +/** + * A track with a thumb at each end of a highlighted span + * + * Dragging grabs whichever thumb is nearer the press and the ends are clamped so start never passes end + */ +@Composable +internal fun RangeSliderTrack( + start: Float, + end: Float, + onValueChange: (start: Float, end: Float) -> Unit, + min: Float, + max: Float, + step: Float, + modifier: Modifier = Modifier, + thumbWidth: Dp = 13.dp, + thumbHeight: Dp = 16.dp, + trackHeight: Dp = 5.dp, +) { + val theme = LocalTheme.current + val currentStart by rememberUpdatedState(start) + val currentEnd by rememberUpdatedState(end) + val currentOnValueChange by rememberUpdatedState(onValueChange) + var trackWidthPx by remember { mutableStateOf(0f) } + + val thumbPx = with(LocalDensity.current) { thumbWidth.toPx() } + val span = (max - min).takeIf { it != 0f } ?: 1f + val startFraction by animateFloatAsState(((minOf(start, end) - min) / span).coerceIn(0f, 1f), animationSpec = spring()) + val endFraction by animateFloatAsState(((maxOf(start, end) - min) / span).coerceIn(0f, 1f), animationSpec = spring()) + + Box( + modifier = modifier + .height(thumbHeight + 3.dp) + .onSizeChanged { trackWidthPx = it.width.toFloat() } + .pointerInput(min, max, step) { + var lastTickValue = Float.NaN + var lastTickAt = 0L + fun maybeTick(newValue: Float) { + if (newValue == lastTickValue) return + lastTickValue = newValue + val now = System.currentTimeMillis() + if (now - lastTickAt >= 70L) { + lastTickAt = now + UiSounds.play(UiSoundEvent.SLIDER_TICK) + } + } + fun snap(raw: Float): Float { + val clamped = raw.coerceIn(min, max) + if (step <= 0f) return clamped + return (min + ((clamped - min) / step).roundToInt() * step).coerceIn(min, max) + } + fun xToValue(x: Float): Float { + val usable = (trackWidthPx - thumbPx).coerceAtLeast(1f) + return snap(min + ((x - thumbPx / 2f) / usable).coerceIn(0f, 1f) * (max - min)) + } + awaitEachGesture { + val down = awaitFirstDown() + var dragStart = minOf(currentStart, currentEnd) + var dragEnd = maxOf(currentStart, currentEnd) + val pressed = xToValue(down.position.x) + val draggingEnd = abs(pressed - dragEnd) <= abs(pressed - dragStart) + fun apply(value: Float) { + if (draggingEnd) dragEnd = value.coerceAtLeast(dragStart) else dragStart = value.coerceAtMost(dragEnd) + maybeTick(value) + currentOnValueChange(dragStart, dragEnd) + } + apply(pressed) + do { + val event = awaitPointerEvent() + event.changes.forEach { change -> + if (change.pressed) { + apply(xToValue(change.position.x)) + change.consume() + } + } + } while (event.changes.any { it.pressed }) + } + } + ) { + Box( + Modifier + .fillMaxWidth().height(trackHeight) + .align(Alignment.CenterStart) + .clip(theme.checkBoxShape) + .background(theme.chipBackground) + ) + val usablePx = (trackWidthPx - thumbPx).coerceAtLeast(0f) + val startTipPx = startFraction * usablePx + thumbPx + val endTipPx = endFraction * usablePx + Box( + Modifier + .align(Alignment.CenterStart) + .offset { IntOffset(startTipPx.toInt(), 0) } + .fillMaxWidth( + if (trackWidthPx <= 0f) 0f else ((endTipPx - startTipPx) / trackWidthPx).coerceAtLeast(0f) + ) + .height(trackHeight) + .clip(theme.checkBoxShape) + .background(Accent) + ) + listOf(startFraction to false, endFraction to true).forEach { (fraction, pointsLeft) -> + TriangleThumb( + pointsLeft = pointsLeft, + color = theme.controlThumbColor, + width = thumbWidth, + height = thumbHeight, + modifier = Modifier + .align(Alignment.CenterStart) + .offset { IntOffset((fraction * usablePx).toInt(), 0) }, + ) + } + } +} + +@Composable +private fun TriangleThumb( + pointsLeft: Boolean, + color: Color, + width: Dp, + height: Dp, + cornerRadius: Dp = 2.dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier.size(width, height)) { + val radius = cornerRadius.toPx() + val corners = if (pointsLeft) { + listOf( + Offset(size.width, 0f), + Offset(0f, size.height / 2f), + Offset(size.width, size.height), + ) + } else { + listOf( + Offset(0f, 0f), + Offset(size.width, size.height / 2f), + Offset(0f, size.height), + ) + } + val path = Path().apply { + corners.forEachIndexed { index, corner -> + val point = corner.insetTowards( + corners[(index + corners.size - 1) % corners.size], + corners[(index + 1) % corners.size], + radius, + ) + if (index == 0) moveTo(point.x, point.y) else lineTo(point.x, point.y) + } + close() + } + drawPath(path, color) + drawPath(path, color, style = Stroke(width = radius * 2f, join = StrokeJoin.Round, cap = StrokeCap.Round)) + } +} + +private fun Offset.insetTowards(previous: Offset, next: Offset, radius: Float): Offset { + val toPrevious = (previous - this).normalizedOrZero() + val toNext = (next - this).normalizedOrZero() + val bisector = (toPrevious + toNext).normalizedOrZero() + if (bisector == Offset.Zero) return this + val sinHalfAngle = abs(toPrevious.x * bisector.y - toPrevious.y * bisector.x) + if (sinHalfAngle < 1e-4f) return this + return this + bisector * (radius / sinHalfAngle) +} + +private fun Offset.normalizedOrZero(): Offset { + val length = getDistance() + return if (length < 1e-4f) Offset.Zero else this / length +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeSliderOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeSliderOption.kt deleted file mode 100644 index c8cd144ad..000000000 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeSliderOption.kt +++ /dev/null @@ -1,232 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.components.settings - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import kotlin.math.abs -import kotlin.math.roundToInt -import org.polyfrost.oneconfig.internal.ui.api.settings.RangeSliderOptionData -import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent -import org.polyfrost.oneconfig.internal.ui.sound.UiSounds -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme - -/** - * A slider with a thumb at each end of a highlighted span - * - * Dragging grabs whichever thumb is nearer the press and the ends are clamped so start never passes end - */ -@Composable -fun RangeSliderOption(data: RangeSliderOptionData) { - val theme = LocalTheme.current - - val initial = data.read() - var start by remember(data.prop) { mutableStateOf(initial?.first ?: data.min) } - var end by remember(data.prop) { mutableStateOf(initial?.second ?: data.max) } - var trackWidthPx by remember { mutableStateOf(0f) } - // which thumb the in-flight drag grabbed so it keeps following the pointer past the other thumb - var draggingEnd by remember { mutableStateOf(false) } - - val thumbWidth = 13.dp - val thumbHeight = 16.dp - val thumbPx = with(LocalDensity.current) { thumbWidth.toPx() } - val span = (data.max - data.min).takeIf { it != 0f } ?: 1f - val startFraction by animateFloatAsState(((start - data.min) / span).coerceIn(0f, 1f), animationSpec = spring()) - val endFraction by animateFloatAsState(((end - data.min) / span).coerceIn(0f, 1f), animationSpec = spring()) - - fun commit() = data.write(start, end) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Box( - modifier = Modifier - // the spinners beside the track outgrow narrow columns like the HUD editor's - .weight(1f, fill = false) - .width(LocalOptionWidth.current) - .height(19.dp) - .onSizeChanged { trackWidthPx = it.width.toFloat() } - .pointerInput(data.min, data.max, data.step) { - var lastTickValue = Float.NaN - var lastTickAt = 0L - fun maybeTick(newValue: Float) { - if (newValue == lastTickValue) return - lastTickValue = newValue - val now = System.currentTimeMillis() - if (now - lastTickAt >= 70L) { - lastTickAt = now - UiSounds.play(UiSoundEvent.SLIDER_TICK) - } - } - fun snap(raw: Float): Float { - val clamped = raw.coerceIn(data.min, data.max) - if (data.step <= 0f) return clamped - return (data.min + ((clamped - data.min) / data.step).roundToInt() * data.step) - .coerceIn(data.min, data.max) - } - fun xToValue(x: Float): Float { - val usable = (trackWidthPx - thumbPx).coerceAtLeast(1f) - return snap(data.min + ((x - thumbPx / 2f) / usable).coerceIn(0f, 1f) * (data.max - data.min)) - } - fun apply(value: Float) { - if (draggingEnd) end = value.coerceAtLeast(start) else start = value.coerceAtMost(end) - maybeTick(value) - commit() - } - awaitEachGesture { - val down = awaitFirstDown() - val pressed = xToValue(down.position.x) - draggingEnd = abs(pressed - end) <= abs(pressed - start) - apply(pressed) - do { - val event = awaitPointerEvent() - event.changes.forEach { change -> - if (change.pressed) { - apply(xToValue(change.position.x)) - change.consume() - } - } - } while (event.changes.any { it.pressed }) - } - } - ) { - Box( - Modifier - .fillMaxWidth().height(5.dp) - .align(Alignment.CenterStart) - .clip(theme.checkBoxShape) - .background(theme.chipBackground) - ) - val usablePx = (trackWidthPx - thumbPx).coerceAtLeast(0f) - val startTipPx = startFraction * usablePx + thumbPx - val endTipPx = endFraction * usablePx - Box( - Modifier - .align(Alignment.CenterStart) - .offset { IntOffset(startTipPx.toInt(), 0) } - .fillMaxWidth( - if (trackWidthPx <= 0f) 0f else ((endTipPx - startTipPx) / trackWidthPx).coerceAtLeast(0f) - ) - .height(5.dp) - .clip(theme.checkBoxShape) - .background(Accent) - ) - listOf(startFraction to false, endFraction to true).forEach { (fraction, pointsLeft) -> - TriangleThumb( - pointsLeft = pointsLeft, - color = theme.controlThumbColor, - width = thumbWidth, - height = thumbHeight, - modifier = Modifier - .align(Alignment.CenterStart) - .offset { IntOffset((fraction * usablePx).toInt(), 0) }, - ) - } - } - NumberSpinner( - value = start, - onValueChange = { start = it.coerceAtMost(end); commit() }, - min = data.min, - max = data.max, - step = if (data.step > 0f) data.step else 1f, - width = 80.dp, - format = data.format, - ) - NumberSpinner( - value = end, - onValueChange = { end = it.coerceAtLeast(start); commit() }, - min = data.min, - max = data.max, - step = if (data.step > 0f) data.step else 1f, - width = 80.dp, - format = data.format, - ) - } -} - -@Composable -private fun TriangleThumb( - pointsLeft: Boolean, - color: Color, - width: Dp, - height: Dp, - cornerRadius: Dp = 2.dp, - modifier: Modifier = Modifier, -) { - Canvas(modifier.size(width, height)) { - val radius = cornerRadius.toPx() - val corners = if (pointsLeft) { - listOf( - Offset(size.width, 0f), - Offset(0f, size.height / 2f), - Offset(size.width, size.height), - ) - } else { - listOf( - Offset(0f, 0f), - Offset(size.width, size.height / 2f), - Offset(0f, size.height), - ) - } - val path = Path().apply { - corners.forEachIndexed { index, corner -> - val point = corner.insetTowards( - corners[(index + corners.size - 1) % corners.size], - corners[(index + 1) % corners.size], - radius, - ) - if (index == 0) moveTo(point.x, point.y) else lineTo(point.x, point.y) - } - close() - } - drawPath(path, color) - drawPath(path, color, style = Stroke(width = radius * 2f, join = StrokeJoin.Round, cap = StrokeCap.Round)) - } -} - -private fun Offset.insetTowards(previous: Offset, next: Offset, radius: Float): Offset { - val toPrevious = (previous - this).normalizedOrZero() - val toNext = (next - this).normalizedOrZero() - val bisector = (toPrevious + toNext).normalizedOrZero() - if (bisector == Offset.Zero) return this - val sinHalfAngle = abs(toPrevious.x * bisector.y - toPrevious.y * bisector.x) - if (sinHalfAngle < 1e-4f) return this - return this + bisector * (radius / sinHalfAngle) -} - -private fun Offset.normalizedOrZero(): Offset { - val length = getDistance() - return if (length < 1e-4f) Offset.Zero else this / length -} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/settings/RangeOptionDataTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/settings/RangeOptionDataTest.java new file mode 100644 index 000000000..808fed0c9 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/settings/RangeOptionDataTest.java @@ -0,0 +1,60 @@ +package org.polyfrost.oneconfig.internal.ui.api.settings; + +import java.util.ArrayList; +import java.util.List; +import kotlin.Pair; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.Properties; +import org.polyfrost.oneconfig.api.config.v1.Property; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RangeOptionDataTest { + @Test + void readClampsAPairStoredOutsideTheBounds() { + Property property = bounded(Properties.simple("range", "Range", null, new int[]{-5, 500}, int[].class)); + RangeOptionData data = new RangeOptionData(property, RangeOptionData.Style.Spinner); + + assertEquals(new Pair<>(0f, 100f), data.read()); + } + + @Test + void rangesClampValuesStoredOutsideTheBounds() { + Property property = bounded(Properties.simple("ranges", "Ranges", null, new int[]{-5, 10, 50, 500}, int[].class)); + RangeListOptionData data = new RangeListOptionData(property, RangeOptionData.Style.Spinner); + + assertEquals(List.of(new Pair<>(0f, 10f), new Pair<>(50f, 100f)), data.ranges()); + } + + @Test + void singleRangesAndRangeListsRoundWholeNumbersTheSameWay() { + Property single = bounded(Properties.simple("range", "Range", null, new int[]{0, 0}, int[].class)); + new RangeOptionData(single, RangeOptionData.Style.Spinner).write(10.7f, 20.5f); + + Property list = bounded(Properties.simple("ranges", "Ranges", null, new int[0], int[].class)); + new RangeListOptionData(list, RangeOptionData.Style.Spinner).setRanges(List.of(new Pair<>(10.7f, 20.5f))); + + assertArrayEquals(new int[]{11, 21}, single.get()); + assertArrayEquals(new int[]{11, 21}, list.get()); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void boxedAndLongStorageRoundsRatherThanTruncates() { + Property> boxed = bounded((Property) Properties.simple("boxed", "Boxed", null, new ArrayList<>(List.of(0, 0)), List.class)); + new RangeOptionData(boxed, RangeOptionData.Style.Spinner).write(10.7f, 20.2f); + + Property longs = bounded(Properties.simple("longs", "Longs", null, new long[]{0, 0}, long[].class)); + new RangeOptionData(longs, RangeOptionData.Style.Spinner).write(10.7f, 20.2f); + + assertEquals(List.of(11, 20), boxed.get()); + assertArrayEquals(new long[]{11, 20}, longs.get()); + } + + private static Property bounded(Property property) { + property.addMetadata("min", 0f); + property.addMetadata("max", 100f); + return property; + } +} From 9212e3832793f15f98cee71e1044434e8ec26426 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 17 Oct 2026 14:50:22 -0400 Subject: [PATCH 2/2] fixes --- .../ui/components/settings/ColorListOption.kt | 2 +- .../ui/components/settings/FileListOption.kt | 2 +- .../ui/components/settings/ItemListOption.kt | 2 +- .../ui/components/settings/ListOptionScaffold.kt | 4 ++-- .../ui/components/settings/NumberListOption.kt | 2 +- .../ui/components/settings/RangeListOption.kt | 2 +- .../ui/components/settings/RangeOption.kt | 16 +++++++++++----- .../ui/components/settings/TextListOption.kt | 2 +- 8 files changed, 19 insertions(+), 13 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt index 2a4ea4de6..9d83af4fb 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt @@ -39,7 +39,7 @@ private val DefaultEntryColor = Color.White @Composable fun ColorListOption(data: ColorListOptionData) { - val state = rememberListEntries(data.prop, data.argb()) { values -> data.setArgb(values) } + val state = rememberListEntries(data.prop, { data.argb() }) { values -> data.setArgb(values) } var openEntry by remember(data.prop) { mutableStateOf(-1) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt index eda4efbed..dfcf027cd 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt @@ -31,7 +31,7 @@ import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme fun FileListOption(data: FileListOptionData) { val scope = rememberCoroutineScope() - val state = rememberListEntries(data.prop, data.strings()) { values -> + val state = rememberListEntries(data.prop, { data.strings() }) { values -> data.setElements(values.filter { it.isNotBlank() }) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt index f4a86fcdb..395b89109 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt @@ -37,7 +37,7 @@ fun ItemListOption(data: ItemListOptionData) { val theme = LocalTheme.current val catalog = rememberItemCatalog(data.prop) val itemById = rememberItemCatalogById(catalog) - val state = rememberListEntries(data.prop, data.ids()) { data.setIds(it) } + val state = rememberListEntries(data.prop, { data.ids() }) { data.setIds(it) } var expanded by remember(data.prop) { mutableStateOf(false) } Box { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt index 1cd9024e5..2a5f2790c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt @@ -125,8 +125,8 @@ internal class ListRowContext( ) @Composable -internal fun rememberListEntries(key: Any?, initial: List, onCommit: (List) -> Unit): ListEntriesState = - remember(key) { ListEntriesState(initial, onCommit) } +internal fun rememberListEntries(key: Any?, initial: () -> List, onCommit: (List) -> Unit): ListEntriesState = + remember(key) { ListEntriesState(initial(), onCommit) } @Composable internal fun ListOptionContainer( diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt index 41da0b011..351a12f4b 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberListOption.kt @@ -11,7 +11,7 @@ import org.polyfrost.oneconfig.internal.ui.api.settings.NumberListOptionData @Composable fun NumberListOption(data: NumberListOptionData) { val elementType = remember(data.prop) { data.elementType } - val state = rememberListEntries(data.prop, data.floats()) { values -> + val state = rememberListEntries(data.prop, { data.floats() }) { values -> data.setElements(values.map { it.toNumberType(elementType) }) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt index eea15ac50..4c32280fb 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeListOption.kt @@ -20,7 +20,7 @@ import org.polyfrost.oneconfig.internal.ui.api.settings.RangeOptionData */ @Composable fun RangeListOption(data: RangeListOptionData) { - val state = rememberListEntries(data.prop, data.ranges()) { ranges -> + val state = rememberListEntries(data.prop, { data.ranges() }) { ranges -> data.setRanges(ranges.map { (start, end) -> minOf(start, end) to maxOf(start, end) }) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt index 76d95f484..1d0feae7f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt @@ -103,7 +103,8 @@ internal val RangeSeparatorWidth = 20.dp * An input for each end of a range laid out in the caller's row * * Typing is free to pass the other end so a number is never clamped midway through being typed and the pair - * is put back in order once the input loses focus while the arrows stop at the other end + * is put back in order once the input loses focus while the arrows stop at the other end unless the pair is + * already out of order * * [separated] puts a dash between the inputs for rows that have no track to read them against */ @@ -128,7 +129,7 @@ internal fun RangeSpinners( step = step, width = width, format = format, - arrowMax = end, + arrowMax = if (start <= end) end else max, onEditEnd = reorder, ) if (separated) { @@ -144,7 +145,7 @@ internal fun RangeSpinners( step = step, width = width, format = format, - arrowMin = start, + arrowMin = if (start <= end) start else min, onEditEnd = reorder, ) } @@ -208,9 +209,14 @@ internal fun RangeSliderTrack( var dragStart = minOf(currentStart, currentEnd) var dragEnd = maxOf(currentStart, currentEnd) val pressed = xToValue(down.position.x) - val draggingEnd = abs(pressed - dragEnd) <= abs(pressed - dragStart) + var draggingEnd = if (dragStart == dragEnd) null else abs(pressed - dragEnd) <= abs(pressed - dragStart) fun apply(value: Float) { - if (draggingEnd) dragEnd = value.coerceAtLeast(dragStart) else dragStart = value.coerceAtMost(dragEnd) + if (draggingEnd == null && value != dragStart) draggingEnd = value > dragStart + when (draggingEnd) { + true -> dragEnd = value.coerceAtLeast(dragStart) + false -> dragStart = value.coerceAtMost(dragEnd) + null -> return + } maybeTick(value) currentOnValueChange(dragStart, dragEnd) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt index 02599ba66..7ba84d822 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt @@ -19,7 +19,7 @@ fun TextListOption(data: TextListOptionData) { val regex = remember(data.regex) { data.regex?.let { runCatching { Regex(it) }.getOrNull() } } fun isValid(value: String) = value.isEmpty() || regex == null || regex.matches(value) - val state = rememberListEntries(data.prop, data.strings()) { values -> + val state = rememberListEntries(data.prop, { data.strings() }) { values -> data.setElements(values.filter { it.isNotBlank() && isValid(it) }) }