From 39036fd52e34eb3b753e2ec3f7673f46896ae7ef Mon Sep 17 00:00:00 2001 From: Tellinq <59412384+Tellinq@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:23:41 -0500 Subject: [PATCH 1/2] feat(hud): add HUD groups --- ...kotlin-compiler-1311474979538203986.salive | 0 modules/hud/api/hud.api | 54 ++ .../org/polyfrost/oneconfig/api/hud/v1/Hud.kt | 18 + .../oneconfig/api/hud/v1/HudGroups.kt | 489 ++++++++++++++++ .../oneconfig/api/hud/v1/HudManager.kt | 40 +- .../oneconfig/api/hud/v1/HudGroupsTest.java | 321 ++++++++++ .../internal/ui/hud/HudSettingsReset.kt | 46 +- .../ui/hud/screens/HudDesignStudio.kt | 551 +++++++++++++++--- .../oneconfig/internal/ui/themes/Impls.kt | 2 + .../resources/assets/oneconfig/ico/group.svg | 8 + .../assets/oneconfig/ico/minecraft/group.svg | 8 + .../oneconfig/ico/minecraft/ungroup.svg | 4 + .../assets/oneconfig/ico/ungroup.svg | 4 + 13 files changed, 1442 insertions(+), 103 deletions(-) create mode 100644 buildSrc/.kotlin/sessions/kotlin-compiler-1311474979538203986.salive create mode 100644 modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt create mode 100644 modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/group.svg create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/group.svg create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/ungroup.svg create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/ungroup.svg diff --git a/buildSrc/.kotlin/sessions/kotlin-compiler-1311474979538203986.salive b/buildSrc/.kotlin/sessions/kotlin-compiler-1311474979538203986.salive new file mode 100644 index 000000000..e69de29bb diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index 1d45c059d..961eaddf6 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -72,6 +72,8 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public fun getExternalPlacement ()Lorg/polyfrost/oneconfig/api/hud/v1/ExternalPlacement; public final fun getFont ()Lorg/polyfrost/oneconfig/api/hud/v1/Font; public final fun getFullShadow ()Z + public final fun getGroup ()Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public final fun getGroupPath ()Ljava/lang/String; public final fun getGrowthAnchor ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; public fun getHidden ()Z public final fun getKeepBgWhenHidden ()Z @@ -158,6 +160,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public fun setCustomScale (F)V public final fun setFont (Lorg/polyfrost/oneconfig/api/hud/v1/Font;)V public final fun setFullShadow (Z)V + public final fun setGroupPath (Ljava/lang/String;)V public final fun setGrowthAnchor (Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor;)V public final fun setGrowthAnchorKeepingPosition (Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor;)V public fun setHidden (Z)V @@ -263,6 +266,57 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudAnchor$Companion { public final fun of (Lorg/polyfrost/compose/layout/PolyAlign;)Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; } +public final class org/polyfrost/oneconfig/api/hud/v1/HudGroup { + public static final field $stable I + public final fun contains (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Z + public final fun contains (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;)Z + public fun equals (Ljava/lang/Object;)Z + public final fun getDepth ()I + public final fun getDirectHuds ()Ljava/util/List; + public final fun getHuds ()Ljava/util/List; + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getParent ()Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public final fun getPath ()Ljava/lang/String; + public final fun getSubGroups ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudGroups { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/hud/v1/HudGroups; + public static final field SEPARATOR C + public static final fun addTo (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;Ljava/util/Collection;)Z + public static final fun addTo (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;Ljava/util/Collection;Ljava/util/Collection;)Z + public static synthetic fun addTo$default (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;Ljava/util/Collection;Ljava/util/Collection;ILjava/lang/Object;)Z + public static final fun all ()Ljava/util/List; + public static final fun bounds (Ljava/util/Collection;)[F + public static final fun bounds (Ljava/util/Collection;Lkotlin/jvm/functions/Function1;)[F + public static synthetic fun bounds$default (Ljava/util/Collection;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)[F + public static final fun chainOf (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Ljava/util/List; + public static final fun group (Ljava/util/Collection;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public static final fun group (Ljava/util/Collection;Ljava/util/Collection;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public static synthetic fun group$default (Ljava/util/Collection;Ljava/util/Collection;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public static final fun moveFrom (Ljava/util/Collection;Ljava/util/Map;FF)V + public static final fun nameOf (Ljava/lang/String;)Ljava/lang/String; + public static final fun of (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public static final fun outermostOf (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public static final fun registerDefault (Ljava/lang/String;Ljava/lang/String;[Ljava/lang/Class;)V + public static final fun removeFromGroup (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)V + public static final fun removeFromGroup (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;)V + public static final fun roots (Ljava/util/Collection;)Ljava/util/List; + public static final fun scaling (Ljava/util/Collection;FF)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroups$Scaling; + public static final fun ungroup (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;)V +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudGroups$Scaling { + public static final field $stable I + public final fun apply (F)F + public final fun getMaxFactor ()F + public final fun getMinFactor ()F +} + public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/hud/v1/HudManager; diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt index 4ba9e2307..0479d7238 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt @@ -1004,6 +1004,22 @@ public abstract class Hud( setAbsolutePosition(absX, absY) } + private var _groupPath: MutableState = mutableStateOf("") + + /** + * The ids of the groups this HUD is in from the outermost down to the one it sits directly in, + * joined by [HudGroups.SEPARATOR], or empty when it is not grouped + * + * Prefer [HudGroups] over setting this directly + */ + @get:ApiStatus.Experimental + @set:ApiStatus.Experimental + public var groupPath: String get() = _groupPath.value; set(v) { _groupPath.put(v) } + + /** The group this HUD sits directly in or `null` when it is not grouped */ + @get:ApiStatus.Experimental + public val group: HudGroup? get() = HudGroups.of(this) + /** Whether [hud] is this HUD's anchor target or the target of one of its targets */ public fun anchorChainContains(hud: Hud): Boolean { var current = anchorParent @@ -1494,6 +1510,7 @@ public abstract class Hud( tree["selfAnchorPoint"] = ktProperty(out::selfAnchorPoint).apply { addDisplayCondition(hideFromConfigUi) } tree["anchorOffsetX"] = ktProperty(out::anchorOffsetX).apply { addDisplayCondition(hideFromConfigUi) } tree["anchorOffsetY"] = ktProperty(out::anchorOffsetY).apply { addDisplayCondition(hideFromConfigUi) } + tree["groupPath"] = ktProperty(out::groupPath).apply { addDisplayCondition(hideFromConfigUi) } } tree["toggleKey"] = ktProperty(out::toggleKey).apply { addDisplayCondition(hideFromConfigUi) } tree["showKey"] = ktProperty(out::showKey).apply { addDisplayCondition(hideFromConfigUi) } @@ -1718,6 +1735,7 @@ public abstract class Hud( _mergeLinkY = mutableStateOf(null) _anchorOffsetX = mutableStateOf(this@Hud.anchorOffsetX) _anchorOffsetY = mutableStateOf(this@Hud.anchorOffsetY) + _groupPath = mutableStateOf(this@Hud.groupPath) _padTop = mutableStateOf(this@Hud.padTop) _padBottom = mutableStateOf(this@Hud.padBottom) _padLeft = mutableStateOf(this@Hud.padLeft) diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt new file mode 100644 index 000000000..15a9d98f5 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt @@ -0,0 +1,489 @@ +/* + * 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.hud.v1 + +import java.util.IdentityHashMap +import androidx.compose.runtime.snapshots.Snapshot +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.oneconfig.api.config.v1.ConfigManager + +/** + * A set of HUDs the editor moves and scales as one box + * + * Groups nest, so a group can hold other groups as well as HUDs + * + * Membership lives on each HUD as [Hud.groupPath] so it is saved, copied and undone with the rest of + * the HUD's options, and a group exists for exactly as long as one of its HUDs does + * + * Two groups are equal when they have the same [path] + */ +@ApiStatus.Experimental +public class HudGroup internal constructor( + /** The ids from the outermost group down to this one joined by [HudGroups.SEPARATOR] */ + public val path: String, +) { + public val id: String get() = path.substring(path.lastIndexOf(HudGroups.SEPARATOR) + 1) + + /** The group this one is nested in, or `null` for a top-level group */ + public val parent: HudGroup? get() { + val i = path.lastIndexOf(HudGroups.SEPARATOR) + return if (i <= 0) null else HudGroup(path.substring(0, i)) + } + + /** How many groups this one is nested in */ + public val depth: Int get() = path.count { it == HudGroups.SEPARATOR } + + /** The name a mod gave this group through [HudGroups.registerDefault], if any */ + public val name: String? get() = HudGroups.nameOf(id) + + /** Every live HUD in this group or in one of its sub-groups */ + public val huds: List get() = HudManager.activeInstances.filter { it in this } + + /** The live HUDs sitting directly in this group rather than in one of its sub-groups */ + public val directHuds: List get() = HudManager.activeInstances.filter { it.groupPath == path } + + /** The groups nested directly in this one */ + public val subGroups: List get() { + val prefix = path + HudGroups.SEPARATOR + val out = LinkedHashSet() + for (hud in HudManager.activeInstances) { + val p = hud.groupPath + if (!p.startsWith(prefix)) continue + val end = p.indexOf(HudGroups.SEPARATOR, prefix.length) + out.add(HudGroup(if (end < 0) p else p.substring(0, end))) + } + return out.toList() + } + + public operator fun contains(hud: Hud): Boolean = HudGroups.isWithin(hud.groupPath, path) + + /** Whether [group] is nested somewhere inside this one, which a group never is in itself */ + public operator fun contains(group: HudGroup): Boolean = group.path != path && HudGroups.isWithin(group.path, path) + + override fun equals(other: Any?): Boolean = other is HudGroup && other.path == path + + override fun hashCode(): Int = path.hashCode() + + override fun toString(): String = "HudGroup($path)" +} + +/** + * Creates, changes and lays out [HudGroup]s + * + * Grouping never changes how a HUD renders in game, every HUD keeps its own position and scale and + * groups only decide what the editor moves and scales together + */ +@ApiStatus.Experimental +public object HudGroups { + public const val SEPARATOR: Char = '/' + + private const val MIN_SCALE = 0.25f + private const val MAX_SCALE = 4f + + /** Backstop for walking a chain of HUDs anchored to one another */ + private const val MAX_ANCHOR_DEPTH = 16 + + private class DefaultGroup(val id: String, val members: List>) + + private val defaults = LinkedHashMap() + private val names = HashMap() + + @Volatile + private var defaultsPending = false + + /** The group [hud] sits directly in, or `null` when it is not grouped */ + @JvmStatic + public fun of(hud: Hud): HudGroup? = hud.groupPath.takeIf { it.isNotEmpty() }?.let(::HudGroup) + + /** Every group [hud] is in from the outermost down to the one it sits directly in */ + @JvmStatic + public fun chainOf(hud: Hud): List { + val path = hud.groupPath + if (path.isEmpty()) return emptyList() + val out = ArrayList(2) + var i = path.indexOf(SEPARATOR) + while (i >= 0) { + out.add(HudGroup(path.substring(0, i))) + i = path.indexOf(SEPARATOR, i + 1) + } + out.add(HudGroup(path)) + return out + } + + /** The outermost group [hud] is in, or `null` when it is not grouped */ + @JvmStatic + public fun outermostOf(hud: Hud): HudGroup? { + val path = hud.groupPath + if (path.isEmpty()) return null + val i = path.indexOf(SEPARATOR) + return HudGroup(if (i < 0) path else path.substring(0, i)) + } + + /** Every group with at least one live HUD, each one listed before the groups nested in it */ + @JvmStatic + public fun all(): List { + val out = LinkedHashSet() + for (hud in HudManager.activeInstances) out.addAll(chainOf(hud)) + return out.sortedBy { it.depth } + } + + @JvmStatic + public fun nameOf(id: String): String? = names[id] + + /** + * Puts [huds] and [groups] into a new group and returns it + * + * The new group goes wherever the items already shared a parent, so grouping two HUDs inside a + * group makes a sub-group while grouping two top-level groups nests both of them in the new one + * + * Returns `null` and changes nothing when there is nothing to group + */ + @JvmStatic + @JvmOverloads + public fun group(huds: Collection, groups: Collection = emptyList()): HudGroup? { + val units = units(huds, groups) + if (units.isEmpty()) return null + val parent = commonParent(units.map { it.parentPath }) + val created = HudGroup(join(parent, newId())) + move(units, created.path) + return created + } + + /** + * Moves [huds] and [groups] into [target] keeping any sub-groups they form + * + * Returns `false` and changes nothing when that would put a group inside itself + */ + @JvmStatic + @JvmOverloads + public fun addTo(target: HudGroup, huds: Collection, groups: Collection = emptyList()): Boolean { + if (groups.any { it == target || target in it }) return false + val units = units(huds, groups).filter { it.parentPath != target.path } + if (units.isEmpty()) return false + move(units, target.path) + return true + } + + /** Dissolves [group], handing everything in it to the group it was nested in */ + @JvmStatic + public fun ungroup(group: HudGroup) { + val parent = group.parent?.path.orEmpty() + Snapshot.withMutableSnapshot { + for (hud in group.huds) hud.groupPath = join(parent, hud.groupPath.substring(group.path.length).trimStart(SEPARATOR)) + } + HudManager.invalidate() + } + + /** Takes [hud] out of the group it sits directly in, handing it to the group around that one */ + @JvmStatic + public fun removeFromGroup(hud: Hud) { + val group = of(hud) ?: return + Snapshot.withMutableSnapshot { hud.groupPath = group.parent?.path.orEmpty() } + HudManager.invalidate() + } + + /** Takes [group] out of the group it is nested in, keeping it whole, and hands it to the group around that one */ + @JvmStatic + public fun removeFromGroup(group: HudGroup) { + val parent = group.parent ?: return + move(listOf(Moving(group.huds, parent.path)), parent.parent?.path.orEmpty()) + } + + /** + * Groups the HUDs of [members] under [id] the first time they are all loaded together + * + * Only HUDs that are not grouped yet are added, and once applied the group is never forced back + * on, so the user can ungroup it and have that stick + * + * [id] must not contain [SEPARATOR] and should be prefixed with the mod id so it cannot clash with + * another mod's group + */ + @JvmStatic + public fun registerDefault(id: String, name: String, vararg members: Class) { + require(id.isNotEmpty() && SEPARATOR !in id) { "HUD group id '$id' must be non-empty and contain no '$SEPARATOR'" } + defaults[id] = DefaultGroup(id, members.toList()) + names[id] = name + defaultsPending = true + } + + internal fun applyPendingDefaults() { + if (!defaultsPending) return + defaultsPending = false + applyDefaults() + } + + internal fun applyDefaults() { + defaultsPending = false + for (default in defaults.values) { + if (HudManager.isGroupKnown(default.id)) continue + val members = HudManager.activeInstances.filter { hud -> + hud.groupPath.isEmpty() && hud.isReal && default.members.any { it.isInstance(hud) } + } + if (members.size < 2) continue + Snapshot.withMutableSnapshot { for (hud in members) hud.groupPath = default.id } + for (hud in members) { + val treeId = hud.tree?.id ?: continue + try { + ConfigManager.active().save(treeId) + } catch (e: Exception) { + HudManager.LOGGER.error("Failed to save HUD $treeId after grouping it", e) + } + } + HudManager.markGroupKnown(default.id) + HudManager.LOGGER.info("Grouped {} HUDs into default group {}", members.size, default.id) + } + } + + /** + * The box around [huds] as `[x, y, width, height]` in gui units, or `null` when none of them has a + * size yet + * + * Read it again whenever it is needed since HUDs change size as their content changes + */ + @JvmStatic + @JvmOverloads + public fun bounds(huds: Collection, boundsOf: (Hud) -> FloatArray? = ::boxOf): FloatArray? { + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + for (hud in huds) { + val b = boundsOf(hud) ?: continue + minX = minOf(minX, b[0]) + minY = minOf(minY, b[1]) + maxX = maxOf(maxX, b[0] + b[2]) + maxY = maxOf(maxY, b[1] + b[3]) + } + if (minX > maxX || minY > maxY) return null + return floatArrayOf(minX, minY, maxX - minX, maxY - minY) + } + + private fun boxOf(hud: Hud): FloatArray = floatArrayOf(hud.x, hud.y, hud.scaledWidth, hud.scaledHeight) + + /** + * The HUDs of [huds] that hold their own place on screen + * + * The rest hang off another HUD in [huds] by an anchor or a fused background, so moving the roots + * carries them along and moving them as well would move them twice + */ + @JvmStatic + public fun roots(huds: Collection): List { + val set = identitySet(huds) + return huds.filter { hud -> + // anchors and fused backgrounds hold each axis separately, so a HUD only rides along when + // both of its axes hang off a HUD that is moving too + val (px, py) = axisParents(hud) + !followsWithin(hud, px, set) || !followsWithin(hud, py, set) + } + } + + /** Moves the roots of [huds] by ([dx], [dy]) from the positions in [starts] */ + @JvmStatic + public fun moveFrom(huds: Collection, starts: Map>, dx: Float, dy: Float) { + // parents first, so a HUD still pinned to a moving one on one axis is placed against where it ended up + for (hud in parentsFirst(roots(huds), huds)) { + val (sx, sy) = starts[hud] ?: continue + hud.setAbsolutePosition(sx + dx, sy + dy) + } + } + + /** + * Scales [huds] as one block about a fixed point + * + * Take one when a resize gesture starts and [apply] it as the gesture goes on, every call scales + * from the sizes and positions the HUDs had at the start + */ + @JvmStatic + public fun scaling(huds: Collection, pivotX: Float, pivotY: Float): Scaling = Scaling(huds, pivotX, pivotY) + + public class Scaling internal constructor(huds: Collection, private val pivotX: Float, private val pivotY: Float) { + private val members: List + private val startScale: FloatArray + private val startX: FloatArray + private val startY: FloatArray + private val startW: FloatArray + private val startH: FloatArray + + /** The smallest and largest factor that keeps every HUD inside the allowed scale range */ + public val minFactor: Float + public val maxFactor: Float + + init { + // anchor parents first so every HUD is placed against where its parent already ended up + members = parentsFirst(huds, huds) + startScale = FloatArray(members.size) { members[it].customScale } + startX = FloatArray(members.size) { members[it].x } + startY = FloatArray(members.size) { members[it].y } + startW = FloatArray(members.size) { members[it].renderedW } + startH = FloatArray(members.size) { members[it].renderedH } + var lo = 0f + var hi = Float.POSITIVE_INFINITY + for (s in startScale) { + if (s <= 0f) continue + lo = maxOf(lo, MIN_SCALE / s) + hi = minOf(hi, MAX_SCALE / s) + } + minFactor = lo.coerceAtMost(1f) + maxFactor = if (hi.isInfinite()) 1f else hi.coerceAtLeast(1f) + } + + /** Scales everything to [factor] times its starting size and returns the factor actually used */ + public fun apply(factor: Float): Float { + val f = factor.coerceIn(minFactor, maxFactor) + // the laid out size only catches up on the next frame, so set it now or every anchor and + // growth point below would be worked out against the old size + for (i in members.indices) { + val hud = members[i] + hud.customScale = startScale[i] * f + if (startW[i] > 0f) hud.renderedW = startW[i] * f + if (startH[i] > 0f) hud.renderedH = startH[i] * f + } + for (i in members.indices) { + members[i].setAbsolutePosition(pivotX + (startX[i] - pivotX) * f, pivotY + (startY[i] - pivotY) * f) + } + return f + } + } + + internal fun isWithin(path: String, ancestor: String): Boolean = + ancestor.isNotEmpty() && path.startsWith(ancestor) && + (path.length == ancestor.length || path[ancestor.length] == SEPARATOR) + + /** A HUD or group being moved into another group along with the parent path it currently sits in */ + private class Moving(val huds: List, val parentPath: String) + + private fun units(huds: Collection, groups: Collection): List { + // a group already holding another selected group or HUD carries it along on its own + val groupList = groups.distinct().filter { g -> groups.none { it != g && g in it } } + val out = ArrayList(huds.size + groupList.size) + for (g in groupList) out.add(Moving(g.huds, g.parent?.path.orEmpty())) + for (hud in huds.distinct()) { + if (groupList.any { hud in it }) continue + out.add(Moving(listOf(hud), hud.groupPath)) + } + return out.filter { it.huds.isNotEmpty() } + } + + private fun move(units: List, into: String) { + Snapshot.withMutableSnapshot { + for (unit in units) { + for (hud in unit.huds) { + val rest = hud.groupPath.substring(unit.parentPath.length).trimStart(SEPARATOR) + hud.groupPath = join(into, rest) + } + } + } + HudManager.invalidate() + } + + private fun commonParent(paths: List): String { + var common = paths.firstOrNull() ?: return "" + for (p in paths) { + while (common.isNotEmpty() && !isWithin(p, common)) { + common = common.substring(0, maxOf(common.lastIndexOf(SEPARATOR), 0)) + } + } + return common + } + + private fun join(parent: String, child: String): String = when { + parent.isEmpty() -> child + child.isEmpty() -> parent + else -> "$parent$SEPARATOR$child" + } + + /** + * The [Hud.groupPath] each copy of [originals] should get when they are duplicated together + * + * A group copied whole becomes a new group of the copies while a group only partly copied keeps + * the copies in the original, so duplicating one HUD leaves it beside the original + */ + @ApiStatus.Internal + @JvmStatic + public fun pathsForCopies(originals: List): List { + val copied = identitySet(originals) + val taken = takenIds() + val renamed = HashMap() + return originals.map { hud -> + chainOf(hud).joinToString(SEPARATOR.toString()) { group -> + if (group.huds.all { it in copied }) renamed.getOrPut(group.path) { newId(taken) } else group.id + } + } + } + + private fun takenIds(): MutableSet { + val taken = HashSet() + for (hud in HudManager.activeInstances) taken.addAll(hud.groupPath.split(SEPARATOR)) + return taken + } + + private fun newId(taken: MutableSet = takenIds()): String { + while (true) { + val id = "g" + Integer.toString((Math.random() * 36.0 * 36 * 36 * 36).toInt(), 36) + if (taken.add(id)) return id + } + } + + private fun identitySet(huds: Collection): MutableSet = + java.util.Collections.newSetFromMap(IdentityHashMap()).also { it.addAll(huds) } + + /** The HUDs [hud]'s x and y positions follow, the same one for both when it is anchored by hand */ + private fun axisParents(hud: Hud): Pair { + val anchor = hud.anchorParent + if (anchor != null) return anchor to anchor + return hud.mergeLinkX?.parent to hud.mergeLinkY?.parent + } + + private fun followsWithin(hud: Hud, parent: Hud?, set: Set): Boolean = + parent != null && parent !== hud && parent in set + + /** + * [huds] sorted so every HUD comes after the HUDs of [moving] it follows + * + * Chains that pass through a HUD outside [moving] count as well, since that HUD is still carried + * by whatever it hangs off + */ + private fun parentsFirst(huds: Collection, moving: Collection): List { + val set = identitySet(moving) + val memo = IdentityHashMap() + fun depth(hud: Hud, steps: Int): Int { + memo[hud]?.let { return it } + if (steps > MAX_ANCHOR_DEPTH) return 0 + // a loop resolves to the screen so treat a HUD already being visited as a root + memo[hud] = 0 + val (px, py) = axisParents(hud) + var d = 0 + for (p in arrayOf(px, py)) { + if (p == null || p === hud) continue + d = maxOf(d, depth(p, steps + 1) + if (p in set) 1 else 0) + } + memo[hud] = d + return d + } + return huds.sortedBy { depth(it, 0) } + } +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt index f9bab2b14..353bba9ec 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt @@ -263,8 +263,10 @@ public object HudManager { private const val REGISTRY_ID = "hud-registry.json" private const val KNOWN_HUDS = "knownHuds" + private const val KNOWN_GROUPS = "knownGroups" private val knownProviders = LinkedHashSet() + private val knownGroups = LinkedHashSet() private var registryTree: Tree? = null private fun registryProperty() = Properties.simple( @@ -273,28 +275,47 @@ public object HudManager { emptyArray(), Array::class.java ) + private fun knownGroupsProperty() = Properties.simple( + KNOWN_GROUPS, "Known HUD groups", + "Default HUD groups which have already been applied once.", + emptyArray(), Array::class.java + ) + private fun loadRegistry() { try { val mgr = ConfigManager.active() val t = mgr.trees().firstOrNull { it.id == REGISTRY_ID } - ?: mgr.register(Tree.tree(REGISTRY_ID).put(registryProperty())).get() + ?: mgr.register(Tree.tree(REGISTRY_ID).put(registryProperty()).put(knownGroupsProperty())).get() if (t.getProp(KNOWN_HUDS) == null) t.put(registryProperty()) + if (t.getProp(KNOWN_GROUPS) == null) t.put(knownGroupsProperty()) t.addMetadata("hidden", true) t.addMetadata(ConfigManager.PROFILE_LOCAL_METADATA, true) registryTree = t - when (val known = t.getProp(KNOWN_HUDS)?.get()) { - is Array<*> -> known.forEach { if (it != null) knownProviders.add(it.toString()) } - is Iterable<*> -> known.forEach { if (it != null) knownProviders.add(it.toString()) } - } + readInto(t.getProp(KNOWN_HUDS)?.get(), knownProviders) + readInto(t.getProp(KNOWN_GROUPS)?.get(), knownGroups) } catch (e: Exception) { LOGGER.error("Failed to load HUD registry, HUD deletions may not persist", e) } } + private fun readInto(stored: Any?, into: MutableSet) { + when (stored) { + is Array<*> -> stored.forEach { if (it != null) into.add(it.toString()) } + is Iterable<*> -> stored.forEach { if (it != null) into.add(it.toString()) } + } + } + + internal fun isGroupKnown(id: String): Boolean = id in knownGroups + + internal fun markGroupKnown(id: String) { + if (knownGroups.add(id)) saveRegistry() + } + private fun saveRegistry() { val t = registryTree ?: return try { t.getProp(KNOWN_HUDS)?.setAs(knownProviders.toTypedArray()) + t.getProp(KNOWN_GROUPS)?.setAs(knownGroups.toTypedArray()) ConfigManager.active().save(REGISTRY_ID) } catch (e: Exception) { LOGGER.error("Failed to save HUD registry", e) @@ -708,6 +729,7 @@ public object HudManager { ): Float { drainProfileReload() noteScreenSize() + if (init) HudGroups.applyPendingDefaults() migratePositions(screenWidth, screenHeight) val scale = Platform.compatibility().options().guiScale prepareScale = scale @@ -1278,6 +1300,7 @@ public object HudManager { } } knownProviders.clear() + knownGroups.clear() registryTree = null zOrderCache = emptyList() preparedFrameValid = false @@ -1499,6 +1522,13 @@ public object HudManager { if (registryChanged) saveRegistry() + try { + HudGroups.applyDefaults() + } catch (e: Throwable) { + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to apply default HUD groups", e) + } + LOGGER.info("HUD load took {}ms, loaded {} HUDs from {} providers ({} registered)", (System.nanoTime() - now) / 1_000_000.0, i, used.size, hudProviders.size) } diff --git a/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java b/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java new file mode 100644 index 000000000..15f8de89a --- /dev/null +++ b/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java @@ -0,0 +1,321 @@ +package org.polyfrost.oneconfig.api.hud.v1; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kotlin.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.ConfigManager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HudGroupsTest { + static abstract class BoxHud extends TextHud { + BoxHud(String id) { super(id, id, Hud.Category.getINFO(), "", ""); } + @Override public boolean showByDefault() { return true; } + @Override public String getText() { return "box"; } + @Override public Pair minimumSize() { return new Pair<>(40f, 10f); } + } + + static class HudA extends BoxHud { HudA() { super("group-a"); } } + static class HudB extends BoxHud { HudB() { super("group-b"); } } + static class HudC extends BoxHud { HudC() { super("group-c"); } } + static class DefaultOne extends BoxHud { DefaultOne() { super("group-default-one"); } } + static class DefaultTwo extends BoxHud { DefaultTwo() { super("group-default-two"); } } + + private static final String DEFAULT_ID = "test.defaults"; + private static boolean registered = false; + + private Hud a; + private Hud b; + private Hud c; + + @BeforeEach + void setUp() throws Exception { + ConfigManager.active(); + ConfigManager.openProfile(""); + if (!registered) { + HudManager.register(new HudA(), new HudB(), new HudC(), new DefaultOne(), new DefaultTwo()); + HudGroups.registerDefault(DEFAULT_ID, "Defaults", DefaultOne.class, DefaultTwo.class); + registered = true; + } + HudManager.guiScreenWidth = 960f; + HudManager.guiScreenHeight = 540f; + relaunch(); + a = only(HudA.class); + b = only(HudB.class); + c = only(HudC.class); + for (Hud hud : Arrays.asList(a, b, c)) { + hud.setGroupPath(""); + hud.clearAnchor(); + hud.setCustomScale(1f); + } + a.setAbsolutePosition(100f, 100f); + b.setAbsolutePosition(140f, 100f); + c.setAbsolutePosition(300f, 300f); + } + + private static Hud only(Class cls) { + List huds = HudManager.INSTANCE.getHudsOfType(cls); + assertEquals(1, huds.size(), "expected one " + cls.getSimpleName()); + return huds.get(0); + } + + private static void relaunch() throws Exception { + HudManager.INSTANCE.getActiveInstances().clear(); + set("registryTree", null); + set("init", false); + for (Hud p : new ArrayList<>(HudManager.INSTANCE.providers())) { + Field f = findField(p.getClass(), "tree"); + f.setAccessible(true); + f.set(p, null); + } + HudManager.INSTANCE.initialize(); + } + + private static void set(String name, Object v) throws Exception { + Field f = HudManager.class.getDeclaredField(name); + f.setAccessible(true); + f.set(HudManager.INSTANCE, v); + } + + private static Object get(String name) throws Exception { + Field f = HudManager.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(HudManager.INSTANCE); + } + + private static Field findField(Class cls, String name) throws Exception { + for (Class k = cls; k != null; k = k.getSuperclass()) { + try { return k.getDeclaredField(name); } catch (NoSuchFieldException ignored) { } + } + throw new NoSuchFieldException(name + " not found on " + cls); + } + + @Test + void groupingAndUngroupingTwoHuds() { + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + assertEquals(group, a.getGroup()); + assertEquals(group, b.getGroup()); + assertNull(c.getGroup()); + assertEquals(2, group.getHuds().size()); + assertTrue(group.contains(a)); + assertFalse(group.contains(c)); + + float[] box = HudGroups.bounds(group.getHuds()); + assertNotNull(box); + assertEquals(100f, box[0], 0.001f, "the box starts at the leftmost HUD"); + assertEquals(80f, box[2], 0.001f, "the box spans both 40 wide HUDs sitting side by side"); + assertEquals(10f, box[3], 0.001f); + + HudGroups.ungroup(group); + assertNull(a.getGroup()); + assertNull(b.getGroup()); + assertTrue(HudGroups.all().isEmpty() || !HudGroups.all().contains(group)); + } + + @Test + void groupingAGroupWithAHudNestsIt() { + HudGroup inner = HudGroups.group(Arrays.asList(a, b)); + HudGroup outer = HudGroups.group(Collections.singletonList(c), Collections.singletonList(inner)); + assertNotNull(inner); + assertNotNull(outer); + + HudGroup nested = a.getGroup(); + assertNotNull(nested); + assertEquals(outer, nested.getParent(), "the old group now sits inside the new one"); + assertEquals(inner.getId(), nested.getId(), "a nested group keeps its id"); + assertEquals(outer, c.getGroup()); + assertEquals(3, outer.getHuds().size()); + assertEquals(Collections.singletonList(nested), outer.getSubGroups()); + assertEquals(Collections.singletonList(c), outer.getDirectHuds()); + assertEquals(outer, HudGroups.outermostOf(a)); + assertEquals(Arrays.asList(outer, nested), HudGroups.chainOf(a)); + + HudGroups.ungroup(outer); + assertEquals(inner, a.getGroup(), "ungrouping the outer group leaves the inner one intact"); + assertNull(c.getGroup()); + } + + @Test + void groupingHudsInsideAGroupMakesASubGroup() { + HudGroup group = HudGroups.group(Arrays.asList(a, b, c)); + assertNotNull(group); + HudGroup sub = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(sub); + assertEquals(group, sub.getParent()); + assertEquals(group, c.getGroup()); + assertEquals(3, group.getHuds().size()); + } + + @Test + void addingToAndRemovingFromAGroup() { + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + assertTrue(HudGroups.addTo(group, Collections.singletonList(c))); + assertEquals(group, c.getGroup()); + + HudGroups.removeFromGroup(c); + assertNull(c.getGroup()); + + HudGroup sub = HudGroups.group(Collections.singletonList(a)); + assertNotNull(sub); + assertFalse(HudGroups.addTo(sub, Collections.emptyList(), Collections.singletonList(group)), + "a group cannot be put inside its own sub-group"); + assertEquals(group, sub.getParent()); + } + + @Test + void movingAGroupOnlyMovesItsRoots() { + b.anchorTo(a, HudAnchor.Right, HudAnchor.Left); + float offsetX = b.getAnchorOffsetX(); + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + + List roots = HudGroups.roots(group.getHuds()); + assertEquals(Collections.singletonList(a), roots, "the anchored HUD follows its parent"); + + Map> starts = new HashMap<>(); + for (Hud hud : group.getHuds()) starts.put(hud, new Pair<>(hud.getX(), hud.getY())); + HudGroups.moveFrom(group.getHuds(), starts, 50f, 20f); + + assertEquals(150f, a.getX(), 0.001f); + assertEquals(120f, a.getY(), 0.001f); + assertEquals(190f, b.getX(), 0.001f, "the anchored HUD moves exactly once"); + assertEquals(120f, b.getY(), 0.001f); + assertEquals(offsetX, b.getAnchorOffsetX(), 0.001f, "moving the group keeps the anchor offset"); + } + + @Test + void movingAGroupAnchoredThroughAnOutsideHud() { + // b hangs off c which hangs off a, and only a and b are grouped + c.setAbsolutePosition(100f, 120f); + b.setAbsolutePosition(100f, 140f); + c.anchorTo(a, HudAnchor.BottomLeft, HudAnchor.TopLeft); + b.anchorTo(c, HudAnchor.BottomLeft, HudAnchor.TopLeft); + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + + assertEquals(2, HudGroups.roots(group.getHuds()).size(), "b follows c, which is not being moved with the group"); + + Map> starts = new HashMap<>(); + for (Hud hud : group.getHuds()) starts.put(hud, new Pair<>(hud.getX(), hud.getY())); + HudGroups.moveFrom(group.getHuds(), starts, 30f, 0f); + + assertEquals(130f, a.getX(), 0.001f); + assertEquals(130f, c.getX(), 0.001f, "the outside HUD follows its anchor"); + assertEquals(130f, b.getX(), 0.001f, "b ends up moved once even though its parent moved too"); + assertEquals(140f, b.getY(), 0.001f); + } + + @Test + void scalingAGroupScalesSizesAndGaps() { + c.setAbsolutePosition(100f, 130f); + b.anchorTo(a, HudAnchor.Right, HudAnchor.Left); + HudGroup group = HudGroups.group(Arrays.asList(a, b, c)); + assertNotNull(group); + + HudGroups.Scaling scaling = HudGroups.scaling(group.getHuds(), 100f, 100f); + float used = scaling.apply(2f); + assertEquals(2f, used, 0.001f); + + for (Hud hud : group.getHuds()) assertEquals(2f, hud.getCustomScale(), 0.001f); + assertEquals(100f, a.getX(), 0.001f, "the pivot stays put"); + assertEquals(100f, a.getY(), 0.001f); + assertEquals(80f, a.getScaledWidth(), 0.001f); + assertEquals(180f, b.getX(), 0.001f, "the anchored HUD is still flush against the grown one"); + assertEquals(100f, c.getX(), 0.001f); + assertEquals(160f, c.getY(), 0.001f, "the gap below the pivot doubles"); + + float[] box = HudGroups.bounds(group.getHuds()); + assertNotNull(box); + assertEquals(160f, box[2], 0.001f); + assertEquals(80f, box[3], 0.001f); + + scaling.apply(1f); + assertEquals(140f, b.getX(), 0.001f, "scaling back restores the starting layout"); + assertEquals(130f, c.getY(), 0.001f); + } + + @Test + void scalingStopsAtTheScaleLimits() { + a.setCustomScale(2f); + HudGroups.Scaling scaling = HudGroups.scaling(Arrays.asList(a, b), 100f, 100f); + assertEquals(2f, scaling.getMaxFactor(), 0.001f, "a is already at 2x so the group can only double"); + assertEquals(2f, scaling.apply(10f), 0.001f); + assertEquals(4f, a.getCustomScale(), 0.001f); + assertEquals(2f, b.getCustomScale(), 0.001f); + assertEquals(0.25f, scaling.getMinFactor(), 0.001f); + } + + @Test + void copiesOfAWholeGroupFormANewGroup() { + HudGroup inner = HudGroups.group(Arrays.asList(a, b)); + HudGroup outer = HudGroups.group(Collections.singletonList(c), Collections.singletonList(inner)); + assertNotNull(outer); + String innerPath = a.getGroupPath(); + + List whole = HudGroups.pathsForCopies(Arrays.asList(a, b, c)); + assertNotEquals(innerPath, whole.get(0), "a fully copied group gets a fresh id"); + assertEquals(whole.get(0), whole.get(1), "the copies stay together"); + assertTrue(whole.get(0).startsWith(whole.get(2) + HudGroups.SEPARATOR), "and keep their nesting"); + assertFalse(whole.get(2).equals(outer.getPath())); + + List partial = HudGroups.pathsForCopies(Collections.singletonList(a)); + assertEquals(innerPath, partial.get(0), "a single copy stays in its original group"); + + List innerOnly = HudGroups.pathsForCopies(Arrays.asList(a, b)); + assertTrue(innerOnly.get(0).startsWith(outer.getPath() + HudGroups.SEPARATOR)); + assertNotEquals(innerPath, innerOnly.get(0), "the copied sub-group is new but stays inside the outer group"); + } + + @Test + void groupsSurviveARelaunch() throws Exception { + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + ConfigManager.active().saveAll(); + + relaunch(); + + assertEquals(group, only(HudA.class).getGroup()); + assertEquals(group, only(HudB.class).getGroup()); + assertNull(only(HudC.class).getGroup()); + } + + @Test + void defaultGroupsApplyOnceAndUngroupingSticks() throws Exception { + // start as if the group had never been applied, earlier runs may have saved it as known + ((Set) get("knownGroups")).remove(DEFAULT_ID); + only(DefaultOne.class).setGroupPath(""); + only(DefaultTwo.class).setGroupPath(""); + Method save = HudManager.class.getDeclaredMethod("saveRegistry"); + save.setAccessible(true); + save.invoke(HudManager.INSTANCE); + ConfigManager.active().saveAll(); + relaunch(); + + assertEquals(DEFAULT_ID, only(DefaultOne.class).getGroupPath(), "the default group is applied on load"); + assertEquals(DEFAULT_ID, only(DefaultTwo.class).getGroupPath()); + assertEquals("Defaults", only(DefaultOne.class).getGroup().getName()); + + HudGroups.ungroup(only(DefaultOne.class).getGroup()); + ConfigManager.active().saveAll(); + relaunch(); + + assertEquals("", only(DefaultOne.class).getGroupPath(), "an ungrouped default group stays ungrouped"); + assertEquals("", only(DefaultTwo.class).getGroupPath()); + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt index fa1c2ebf7..cd74108b8 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt @@ -82,6 +82,9 @@ private val POSITION_OPTIONS = setOf( "anchorTargetId", "anchorPoint", "selfAnchorPoint", "anchorOffsetX", "anchorOffsetY", ) +// which group a HUD is in is structure rather than a setting, so resetting the HUD keeps it grouped +private val UNRESETTABLE_OPTIONS = setOf("groupPath") + /** Bumped when any HUD field is reset so bound [remember] state is recreated from the HUD */ fun hudUiEpoch(hud: Hud): Int = hudUiEpochs[hud] ?: 0 @@ -106,7 +109,10 @@ fun hudHasResettableDefaults(hud: Hud): Boolean { private fun treeHasResettableDefaults(tree: Tree, root: Boolean): Boolean { for (node in tree.map.values) { when (node) { - is Property<*> -> if (!(root && (node.id ?: node.getID()) in POSITION_OPTIONS) && optionHasDefault(node)) return true + is Property<*> -> { + val optionId = node.id ?: node.getID() + if (!(root && optionId in POSITION_OPTIONS) && optionId !in UNRESETTABLE_OPTIONS && optionHasDefault(node)) return true + } is Tree -> if (treeHasResettableDefaults(node, root = false)) return true } } @@ -220,7 +226,7 @@ private fun resetTreeDefaults(tree: Tree, root: Boolean, seen: MutableSet -> { val optionId = node.id ?: node.getID() val builtIn = root && (optionId in STATIC_SIZE_OPTIONS || optionId in POSITION_OPTIONS) - if (!builtIn && optionHasDefault(node)) resetOption(node, seen) + if (!builtIn && optionId !in UNRESETTABLE_OPTIONS && optionHasDefault(node)) resetOption(node, seen) } is Tree -> resetTreeDefaults(node, root = false, seen = seen) } @@ -343,6 +349,13 @@ fun HudCanvasResetMenu( redoEnabled: Boolean = false, preview: Boolean = false, onMeasured: (IntSize) -> Unit = {}, + /** [targets] split into what moves as one block, so a selected group aligns as a whole */ + units: List> = emptyList(), + onGroup: (() -> Unit)? = null, + onUngroup: (() -> Unit)? = null, + onRemoveFromGroup: (() -> Unit)? = null, + /** Existing groups the targets can be moved into, each with its label */ + addToGroups: List Unit>> = emptyList(), ) { if (hud == null || !expanded) return val theme = LocalTheme.current @@ -416,7 +429,7 @@ fun HudCanvasResetMenu( } HudAlignRow { align -> onDismiss() - alignHuds(actionTargets, align) + alignHuds(units.ifEmpty { actionTargets.map(::listOf) }, align) } Box( modifier = if (preview) { @@ -444,6 +457,33 @@ fun HudCanvasResetMenu( onDismiss() } } + if (onGroup != null || onUngroup != null || onRemoveFromGroup != null || addToGroups.isNotEmpty()) { + HudMenuDivider() + } + if (onGroup != null) { + HudMenuRow(icon = "group", text = "Group") { + onGroup() + onDismiss() + } + } + if (onUngroup != null) { + HudMenuRow(icon = "ungroup", text = "Ungroup") { + onUngroup() + onDismiss() + } + } + for ((label, onAdd) in addToGroups) { + HudMenuRow(icon = "plus", text = "Add to $label") { + onAdd() + onDismiss() + } + } + if (onRemoveFromGroup != null) { + HudMenuRow(icon = "minus", text = "Remove from group") { + onRemoveFromGroup() + onDismiss() + } + } HudMenuDivider() HudMenuRow(icon = "copy", text = "Copy", hint = copyHint) { onCopy(hud) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt index 498527596..c3fd57921 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt @@ -84,6 +84,8 @@ import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.hud.v1.ExternalPlacement import org.polyfrost.oneconfig.api.hud.v1.Hud import org.polyfrost.oneconfig.api.hud.v1.HudAnchor +import org.polyfrost.oneconfig.api.hud.v1.HudGroup +import org.polyfrost.oneconfig.api.hud.v1.HudGroups import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.api.hud.v1.HudResize import org.polyfrost.oneconfig.api.hud.v1.LegacyHudMarker as LegacyHud @@ -123,6 +125,9 @@ private val LOGGER = LogManager.getLogger("OneConfig/HudDesignStudio") /** How far the cursor must travel while pressing a HUD before it counts as a drag attempt */ private const val DRAG_SLOP_PX = 3f +/** Longest gap between two clicks on a HUD that still steps into its group */ +private const val DOUBLE_CLICK_MS = 400L + private var lockedHudToast: Notification? = null /** @@ -226,6 +231,11 @@ private val hoveredHudBoxColor = Color.White.copy(0.5f) private val hiddenHudBoxColor = Color.White.copy(0.4f) private val hiddenHudDashEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 3f), 0f) +// a group draws one box around its HUDs while the HUDs inside keep a faint one +private val groupMemberBoxColor = Color(SELECTION_BLUE_ARGB).copy(alpha = 0.45f) +private val groupHoverDashEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 3f), 0f) +private val enteredGroupBoxColor = Color.White.copy(0.35f) + // locked HUDs get a dashed amber box instead of the plain white one private val lockedHudBoxColor = Color(0xFFE0A44C) private val lockedHudDashEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f), 0f) @@ -249,6 +259,8 @@ private const val SIDE_CHROME_DIM_ALPHA = 0.45f /** An unlocked HUD press that becomes a click or drag depending on whether it moves past [DRAG_SLOP_PX] */ private class PendingPress( val hud: Hud, + /** [hud] or every HUD of the group the press picked */ + val unit: Set, val origin: Offset, val wasSelected: Boolean, val narrowsSelection: Boolean, @@ -361,16 +373,21 @@ internal enum class HudAlign(val icon: String, val label: String) { Top("align-top", "Align top"), CenterY("align-middle", "Align middle"), Bottom("align-bottom", "Align bottom"), } -internal fun alignHuds(huds: List, align: HudAlign) { - val boxes = huds.mapNotNull { hud -> hudBounds(hud)?.let { hud to it } } +/** + * Lines up [units] against each other, or against the screen when there is only one + * + * Each unit is a single HUD or every HUD of a group, and a group moves as one block + */ +internal fun alignHuds(units: List>, align: HudAlign) { + val boxes = units.mapNotNull { unit -> boundsOf(unit)?.let { unit to it } } if (boxes.isEmpty()) return - if (boxes.any { it.first.locked }) notifyHudLocked() + if (boxes.any { (unit, _) -> unit.any { it.locked } }) notifyHudLocked() val single = boxes.size == 1 val x0 = if (single) 0f else boxes.minOf { it.second.x } val y0 = if (single) 0f else boxes.minOf { it.second.y } val x1 = if (single) HudManager.guiScreenWidth else boxes.maxOf { it.second.x + it.second.width } val y1 = if (single) HudManager.guiScreenHeight else boxes.maxOf { it.second.y + it.second.height } - val targets = boxes.filter { !it.first.locked }.map { (hud, b) -> + val moves = boxes.filter { (unit, _) -> unit.none { it.locked } }.map { (unit, b) -> val dx = when (align) { HudAlign.Left -> x0 - b.x HudAlign.CenterX -> (x0 + x1 - b.width) / 2f - b.x @@ -383,9 +400,9 @@ internal fun alignHuds(huds: List, align: HudAlign) { HudAlign.Bottom -> y1 - b.height - b.y else -> 0f } - Triple(hud, hud.x + dx, hud.y + dy) + HudGroups.roots(unit).map { Triple(it, it.x + dx, it.y + dy) } } - applyHudTargets(targets) + applyHudTargets(moves.flatten()) } private fun hudBounds(hud: Hud): HudBounds? { @@ -416,6 +433,64 @@ private fun hudBounds(hud: Hud): HudBounds? { return HudBounds(hud.x, hud.y, width, height) } +/** The box around every HUD of [huds], read fresh each time since grouped HUDs change size too */ +private fun boundsOf(huds: Collection): HudBounds? { + if (huds.size == 1) return hudBounds(huds.first()) + val box = HudGroups.bounds(huds) { hud -> hudBounds(hud)?.let { floatArrayOf(it.x, it.y, it.width, it.height) } } + ?: return null + return HudBounds(box[0], box[1], box[2], box[3]) +} + +/** + * The group a click on [hud] picks + * + * That is the outermost group it is in, or once the user has stepped into one of its groups the + * next group down, and `null` when the click picks [hud] on its own + */ +private fun unitGroupOf(hud: Hud, entered: HudGroup?): HudGroup? { + val chain = HudGroups.chainOf(hud) + if (chain.isEmpty()) return null + val inside = if (entered == null) -1 else chain.indexOf(entered) + return chain.getOrNull(inside + 1) +} + +/** Every HUD a click on [hud] selects, with [hud] itself last so it is the one the settings panel shows */ +private fun unitOf(hud: Hud, entered: HudGroup?): Set { + val group = unitGroupOf(hud, entered) ?: return setOf(hud) + val out = LinkedHashSet() + for (member in group.huds) if (member !== hud) out.add(member) + out.add(hud) + return out +} + +/** The groups whose every HUD is in [selected] at the level the user is currently picking at */ +private fun selectedGroupsIn(selected: Set, entered: HudGroup?): List { + val out = ArrayList() + for (hud in selected) { + val group = unitGroupOf(hud, entered) ?: continue + if (group !in out && group.huds.all { it in selected }) out.add(group) + } + return out +} + +/** [selected] split into what a click would pick: each fully selected group as a block and every other HUD alone */ +private fun selectionUnits(selected: Set, entered: HudGroup?): List> { + val groups = selectedGroupsIn(selected, entered) + val out = ArrayList>(groups.size + selected.size) + for (group in groups) out.add(group.huds) + for (hud in selected) if (groups.none { hud in it }) out.add(listOf(hud)) + return out +} + +internal fun hudGroupLabel(group: HudGroup): String { + group.name?.let { return it } + val titles = group.huds.map { localizedLabel(it.title) ?: it.title } + return when { + titles.size <= 2 -> titles.joinToString(", ") + else -> "${titles[0]}, ${titles[1]} +${titles.size - 2}" + } +} + /** The nine points of a HUD box the user can pin another HUD to in reading order */ private val ANCHOR_POINTS = listOf( HudAnchor.TopLeft, HudAnchor.Top, HudAnchor.TopRight, @@ -580,8 +655,16 @@ private fun hudActionBarLayout( mcToScreen: Float, iconPx: Float, gapPx: Float, -): HudActionBarLayout? { - val bounds = hudBounds(hud) ?: return null +): HudActionBarLayout? = hudBounds(hud)?.let { hudActionBarLayout(it, hudActionBarIcons(hud), mcToScreen, iconPx, gapPx) } + +/** The action bar for a box holding [icons] buttons, which is one HUD's box or a whole group's */ +private fun hudActionBarLayout( + bounds: HudBounds, + icons: Int, + mcToScreen: Float, + iconPx: Float, + gapPx: Float, +): HudActionBarLayout { val sx = bounds.x * mcToScreen val sy = bounds.y * mcToScreen val sw = bounds.width * mcToScreen @@ -595,7 +678,6 @@ private fun hudActionBarLayout( .takeIf { it > 0f } ?: (HudManager.guiScreenHeight * mcToScreen) val padPx = actionBarPadding(gapPx) - val icons = hudActionBarIcons(hud) val barWidth = iconPx * icons + gapPx * (icons - 1) + padPx * 2f val barHeight = iconPx + padPx * 2f val maxLeft = (screenW - barWidth).coerceAtLeast(0f) @@ -623,6 +705,19 @@ private fun hitTestHudActionBar( gapPx: Float, ): Boolean = hudActionBarLayout(hud, mcToScreen, iconPx, gapPx)?.contains(screenX, screenY) == true +private fun hitTestHudActionBarZone( + hud: Hud, + screenX: Float, + screenY: Float, + mcToScreen: Float, + iconPx: Float, + gapPx: Float, +): Boolean { + val bounds = hudBounds(hud) ?: return false + val bar = hudActionBarLayout(bounds, hudActionBarIcons(hud), mcToScreen, iconPx, gapPx) + return hitTestHudActionBarZone(bounds, bar, screenX, screenY, mcToScreen) +} + /** * The bar plus the thin corridor between it and the HUD box * @@ -630,16 +725,13 @@ private fun hitTestHudActionBar( * HUD and without claiming it the hover flips mid-travel */ private fun hitTestHudActionBarZone( - hud: Hud, + bounds: HudBounds, + bar: HudActionBarLayout, screenX: Float, screenY: Float, mcToScreen: Float, - iconPx: Float, - gapPx: Float, ): Boolean { - val bar = hudActionBarLayout(hud, mcToScreen, iconPx, gapPx) ?: return false if (bar.contains(screenX, screenY)) return true - val bounds = hudBounds(hud) ?: return false val hudLeft = bounds.x * mcToScreen val hudTop = bounds.y * mcToScreen val hudRight = hudLeft + bounds.width * mcToScreen @@ -657,36 +749,45 @@ private fun hitTestHudActionBarZone( return screenX >= minOf(hudLeft, bar.x) && screenX <= maxOf(hudRight, bar.x + bar.width) } +private fun hitTestHudWithActionBar( + hud: Hud, + screenX: Float, + screenY: Float, + mcToScreen: Float, + iconPx: Float, + gapPx: Float, +): Boolean { + val bounds = hudBounds(hud) ?: return false + val bar = hudActionBarLayout(bounds, hudActionBarIcons(hud), mcToScreen, iconPx, gapPx) + return hitTestHudWithActionBar(bounds, bar, screenX, screenY, mcToScreen) +} + /** * The HUD box unioned with its action bar * * The bar sits outside the box with a gap in between so hover has to survive the whole span */ private fun hitTestHudWithActionBar( - hud: Hud, + bounds: HudBounds, + bar: HudActionBarLayout, screenX: Float, screenY: Float, mcToScreen: Float, - iconPx: Float, - gapPx: Float, ): Boolean { - val bounds = hudBounds(hud) ?: return false - var left = bounds.x * mcToScreen - var top = bounds.y * mcToScreen - var right = left + bounds.width * mcToScreen - var bottom = top + bounds.height * mcToScreen - hudActionBarLayout(hud, mcToScreen, iconPx, gapPx)?.let { bar -> - left = minOf(left, bar.x) - top = minOf(top, bar.y) - right = maxOf(right, bar.x + bar.width) - bottom = maxOf(bottom, bar.y + bar.height) - } + val left = minOf(bounds.x * mcToScreen, bar.x) + val top = minOf(bounds.y * mcToScreen, bar.y) + val right = maxOf((bounds.x + bounds.width) * mcToScreen, bar.x + bar.width) + val bottom = maxOf((bounds.y + bounds.height) * mcToScreen, bar.y + bar.height) return screenX >= left && screenX <= right && screenY >= top && screenY <= bottom } private fun hitTestResizeHandle(hud: Hud, screenX: Float, screenY: Float, mcToScreen: Float): ResizeCorner? { if (hud.resizeAxes == HudResize.None) return null val bounds = hudBounds(hud) ?: return null + return hitTestResizeHandle(bounds, screenX, screenY, mcToScreen) +} + +private fun hitTestResizeHandle(bounds: HudBounds, screenX: Float, screenY: Float, mcToScreen: Float): ResizeCorner? { val sx = bounds.x * mcToScreen val sy = bounds.y * mcToScreen val sw = bounds.width * mcToScreen @@ -705,6 +806,22 @@ private fun hitTestResizeHandle(hud: Hud, screenX: Float, screenY: Float, mcToSc } } +/** + * How far the dragged [corner] has moved from where it started, as a factor of the diagonal + * + * The cursor is projected onto the diagonal from the fixed corner to the dragged one because a + * per-axis factor turns small moves on the short axis into huge jumps + */ +private fun cornerScaleFactor(corner: ResizeCorner, start: HudBounds, mouseX: Float, mouseY: Float): Float { + val anchorX = if (corner.isLeft) start.x + start.width else start.x + val anchorY = if (corner.isTop) start.y + start.height else start.y + val cornerVecX = if (corner.isLeft) -start.width else start.width + val cornerVecY = if (corner.isTop) -start.height else start.height + val diagLenSq = cornerVecX * cornerVecX + cornerVecY * cornerVecY + if (diagLenSq <= 0f) return 1f + return ((mouseX - anchorX) * cornerVecX + (mouseY - anchorY) * cornerVecY) / diagLenSq +} + private fun resizeHud( hud: Hud, corner: ResizeCorner, @@ -736,17 +853,7 @@ private fun resizeHud( return } - // scale proportionally by projecting the mouse onto the diagonal from the anchor to the dragged - // corner because a per-axis max-deviation factor turns small moves on the short axis into huge jumps - val cornerVecX = if (corner.isLeft) -startBounds.width else startBounds.width - val cornerVecY = if (corner.isTop) -startBounds.height else startBounds.height - val mouseVecX = mouseX - anchorX - val mouseVecY = mouseY - anchorY - val diagLenSq = cornerVecX * cornerVecX + cornerVecY * cornerVecY - val rawFactor = if (diagLenSq > 0f) { - (mouseVecX * cornerVecX + mouseVecY * cornerVecY) / diagLenSq - } else 1f - hud.customScale = (startScale.coerceAtLeast(0.001f) * rawFactor).coerceIn(0.25f, 4f) + hud.customScale = (startScale.coerceAtLeast(0.001f) * cornerScaleFactor(corner, startBounds, mouseX, mouseY)).coerceIn(0.25f, 4f) } private fun DrawScope.drawSelectedHudBounds( @@ -874,28 +981,40 @@ private fun HudActionButton( } } +/** What the action bar's group button does */ +private enum class GroupAction(val icon: String) { + /** Groups everything selected along with the hovered HUD */ + Group("group"), + + /** Dissolves the hovered group */ + Ungroup("ungroup"), +} + +private fun actionBarIcons(huds: Collection, groupAction: GroupAction?): Int = + HUD_ACTION_BAR_ICONS + (if (huds.any { it.canDelete() }) 1 else 0) + (if (groupAction != null) 1 else 0) + @OptIn(ExperimentalComposeUiApi::class) @Composable private fun HudActionBar( - hud: Hud, - mcToScreen: Float, + huds: List, + layout: HudActionBarLayout, densityFloat: Float, actionIconPx: Float, actionBarGapPx: Float, chromeAlpha: Float, + groupAction: GroupAction? = null, + onGroupAction: () -> Unit = {}, onDelete: () -> Unit, onSettings: () -> Unit, ) { - val bounds = hudBounds(hud) ?: return - if (bounds.width <= 0f || bounds.height <= 0f) return - val layout = hudActionBarLayout(hud, mcToScreen, actionIconPx, actionBarGapPx) ?: return + if (huds.isEmpty()) return val shape = LocalTheme.current.buttonShape val iconSize = (actionIconPx / densityFloat).dp val barX = (layout.x / densityFloat).coerceAtLeast(0f) val barY = (layout.y / densityFloat).coerceAtLeast(0f) val barPadding = (actionBarPadding(actionBarGapPx) / densityFloat).dp - val isHidden = hud.hidden - val isLocked = hud.locked + val isHidden = huds.all { it.hidden } + val isLocked = huds.all { it.locked } Row( modifier = Modifier .padding(start = barX.dp, top = barY.dp) @@ -921,7 +1040,10 @@ private fun HudActionBar( hoveredForeground = Color.White, sound = UiSoundEvent.CLICK, ) { - Snapshot.withMutableSnapshot { hud.hidden = !hud.hidden } + Snapshot.withMutableSnapshot { + val hide = huds.any { !it.hidden } + huds.forEach { it.hidden = hide } + } } HudActionButton( if (isLocked) "lock" else "unlock", @@ -930,9 +1052,22 @@ private fun HudActionBar( hoveredForeground = if (isLocked) Accent else Color.White, sound = UiSoundEvent.CLICK, ) { - Snapshot.withMutableSnapshot { hud.locked = !hud.locked } + Snapshot.withMutableSnapshot { + val lock = huds.any { !it.locked } + huds.forEach { it.locked = lock } + } + } + if (groupAction != null) { + HudActionButton( + groupAction.icon, + iconSize, + foreground = Color.White.copy(0.7f), + hoveredForeground = Color.White, + sound = UiSoundEvent.CLICK, + onClick = onGroupAction, + ) } - if (hud.canDelete()) { + if (huds.any { it.canDelete() }) { HudActionButton( "trash", iconSize, @@ -1066,6 +1201,12 @@ fun HudDesignStudio(visible: Boolean = true) { var resizeCorner by remember { mutableStateOf(null) } var resizeStartBounds by remember { mutableStateOf(null) } var resizeStartScale by remember { mutableStateOf(1f) } + // set instead of resizedHud while a group selected on its own is scaled from its corner handles + var groupScaling by remember { mutableStateOf(null) } + // the group the user double-clicked into, clicks then pick the HUDs and sub-groups inside it + var enteredGroup by remember { mutableStateOf(null) } + var lastClickHud by remember { mutableStateOf(null) } + val lastClickTime = remember { LongArray(1) } var libraryVisible by remember { mutableStateOf(true) } var searchText by remember { mutableStateOf("") } var searchFocusTarget by remember { mutableStateOf(null) } @@ -1126,6 +1267,24 @@ fun HudDesignStudio(visible: Boolean = true) { // the active HUD of a selection driving the settings panel resize handles action bar and keybinds fun primaryHud(): Hud? = selectedHuds.lastOrNull() + /** The group selected on its own, which gets a single box with resize handles that scale the whole group */ + fun soleGroup(): HudGroup? = + selectedGroupsIn(selectedHuds, enteredGroup).singleOrNull()?.takeIf { it.huds.size == selectedHuds.size } + + fun groupSelection() { + val groups = selectedGroupsIn(selectedHuds, enteredGroup) + val loose = selectedHuds.filter { hud -> groups.none { hud in it } } + if (HudGroups.group(loose, groups) != null) UiSounds.play(UiSoundEvent.CLICK) + } + + fun ungroup(groups: Collection) { + if (groups.isEmpty()) return + // stepping out first keeps a click from picking inside a group that no longer exists + if (enteredGroup?.let { entered -> groups.any { it == entered || entered in it } } == true) enteredGroup = null + groups.forEach(HudGroups::ungroup) + UiSounds.play(UiSoundEvent.CLICK) + } + fun closeLibrary() { libraryVisible = false pendingLibraryScroll = null @@ -1445,6 +1604,26 @@ fun HudDesignStudio(visible: Boolean = true) { val actionBarGapPx = with(densityObj) { 8.dp.toPx() } val libraryChromeVisible = selectedHuds.isEmpty() && !isDragging && !marqueeActive + /** + * What the group button on [hud]'s action bar does + * + * Hovering part of a selection of several HUDs or groups offers to group them, otherwise hovering a + * group offers to ungroup it + */ + fun groupActionFor(hud: Hud): GroupAction? { + val unit = unitOf(hud, enteredGroup) + if (unit.all { it in selectedHuds } && selectionUnits(selectedHuds, enteredGroup).size >= 2) return GroupAction.Group + return if (unitGroupOf(hud, enteredGroup) != null) GroupAction.Ungroup else null + } + + /** The box the action bar for hovering [hud] hangs off, which is its whole group's when it is grouped, and the bar */ + fun hoverBar(hud: Hud, mcToScreen: Float): Pair? { + val unit = unitOf(hud, enteredGroup) + val bounds = boundsOf(unit) ?: return null + val icons = actionBarIcons(unit, groupActionFor(hud)) + return bounds to hudActionBarLayout(bounds, icons, mcToScreen, actionIconPx, actionBarGapPx) + } + fun Modifier.chromeRegion(key: String) = onGloballyPositioned { coords -> val root = rootCoords[0]?.takeIf { it.isAttached } ?: return@onGloballyPositioned chromeRects[key] = root.localBoundingBoxOf(coords) @@ -1500,7 +1679,7 @@ fun HudDesignStudio(visible: Boolean = true) { if (hud.locked) return@filter false val b = hudBounds(hud) ?: return@filter false b.x < right && b.x + b.width > left && b.y < bottom && b.y + b.height > top - }.toSet() + }.flatMapTo(LinkedHashSet()) { unitOf(it, enteredGroup) } selectedHuds = if (marqueeAdditive) selectedHuds + intersecting else intersecting } @@ -1558,9 +1737,10 @@ fun HudDesignStudio(visible: Boolean = true) { Snapshot.withMutableSnapshot { pressWasSecondary = true if (hit != null) { + if (enteredGroup?.let { it !in HudGroups.chainOf(hit) } == true) enteredGroup = null if (hit !in selectedHuds) { UiSounds.play(UiSoundEvent.HUD_SELECT) - selectedHuds = setOf(hit) + selectedHuds = unitOf(hit, enteredGroup) } hudContextMenuTarget = hit hudContextMenuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) @@ -1576,7 +1756,32 @@ fun HudDesignStudio(visible: Boolean = true) { pressWasSecondary = false pendingPress = null val mcToScreen = Platform.screen().mcToScreenScale() - val singleSelection = selectedHuds.size == 1 + val group = soleGroup() + val groupHuds = group?.huds.orEmpty() + val groupBounds = if (group != null) boundsOf(groupHuds) else null + val groupHandle = groupBounds?.let { hitTestResizeHandle(it, pos.x, pos.y, mcToScreen) } + if (groupBounds != null && groupHandle != null) { + event.changes.forEach { it.consume() } + if (groupHuds.any { it.locked }) { + notifyHudLocked() + return@safePointerEvent + } + UiSounds.play(UiSoundEvent.HUD_RESIZE_START) + // the corner opposite the handle stays put while the group scales + val pivotX = if (groupHandle.isLeft) groupBounds.x + groupBounds.width else groupBounds.x + val pivotY = if (groupHandle.isTop) groupBounds.y + groupBounds.height else groupBounds.y + Snapshot.withMutableSnapshot { + groupScaling = HudGroups.scaling(groupHuds, pivotX, pivotY) + isResizing = true + resizedHud = null + resizeCorner = groupHandle + resizeStartBounds = groupBounds + closeLibrary() + pasteMenuOffset = null + } + return@safePointerEvent + } + val singleSelection = selectedHuds.size == 1 && group == null val selected = if (singleSelection) primaryHud() else null if (selected != null && !selected.locked) { val handle = hitTestResizeHandle(selected, pos.x, pos.y, mcToScreen) @@ -1606,7 +1811,7 @@ fun HudDesignStudio(visible: Boolean = true) { } } // clicks inside the bar belong to its buttons so never select or drag the HUD behind it - if (hoveredHud?.let { hitTestHudActionBar(it, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) } == true) { + if (hoveredHud?.let { hoverBar(it, mcToScreen)?.second?.contains(pos.x, pos.y) } == true) { Snapshot.withMutableSnapshot { pasteMenuOffset = null } return@safePointerEvent } @@ -1614,11 +1819,16 @@ fun HudDesignStudio(visible: Boolean = true) { val actionPressed = KeybindUtils.isActionModifierPressed(event.keyboardModifiers) val shift = event.keyboardModifiers.isShiftPressed val hit = pickHudAt(pos.x, pos.y, primaryHud()) + // a click outside the group the user stepped into steps back out of it + if (hit == null && !actionPressed && !shift || hit != null && enteredGroup?.let { it !in HudGroups.chainOf(hit) } == true) { + enteredGroup = null + } if (hit != null && hit.locked) { event.changes.forEach { it.consume() } if (hit !in selectedHuds) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { - selectedHuds = if (actionPressed) selectedHuds + hit else setOf(hit) + val unit = unitOf(hit, enteredGroup) + selectedHuds = if (actionPressed) selectedHuds + unit else unit closeLibrary() lockedPressHud = hit lockedPressOrigin = pos @@ -1628,19 +1838,29 @@ fun HudDesignStudio(visible: Boolean = true) { if (hit != null) event.changes.forEach { it.consume() } Snapshot.withMutableSnapshot { if (hit != null) { - val wasSelected = hit in selectedHuds + // a second click on a grouped HUD steps into the group and picks what is under the cursor inside it + val now = System.currentTimeMillis() + val doubleClick = !actionPressed && !shift && lastClickHud === hit && + now - lastClickTime[0] <= DOUBLE_CLICK_MS + val stepInto = if (doubleClick) unitGroupOf(hit, enteredGroup) else null + if (stepInto != null) enteredGroup = stepInto + lastClickHud = if (doubleClick) null else hit + lastClickTime[0] = now + val unit = unitOf(hit, enteredGroup) + val wasSelected = stepInto == null && unit.all { it in selectedHuds } val newSelection = when { - actionPressed -> if (wasSelected) selectedHuds - hit else selectedHuds + hit - shift -> if (wasSelected) selectedHuds else selectedHuds + hit - else -> if (wasSelected) selectedHuds else setOf(hit) + actionPressed -> if (wasSelected) selectedHuds - unit else selectedHuds + unit + shift -> if (wasSelected) selectedHuds else selectedHuds + unit + else -> if (wasSelected) selectedHuds else unit } selectedHuds = newSelection closeLibrary() pendingPress = PendingPress( hud = hit, + unit = unit, origin = pos, wasSelected = wasSelected, - narrowsSelection = wasSelected && !actionPressed && !shift && newSelection.size > 1, + narrowsSelection = wasSelected && !actionPressed && !shift && newSelection.size > unit.size, ) dragOffsetX = pos.x * s - hit.x dragOffsetY = pos.y * s - hit.y @@ -1679,6 +1899,12 @@ fun HudDesignStudio(visible: Boolean = true) { } else { if (press.hud !in selectedHuds || press.hud.locked) return@safePointerEvent if ((pos - press.origin).getDistance() <= DRAG_SLOP_PX) return@safePointerEvent + // a group only moves whole so one locked HUD in it holds the group in place + if (press.unit.any { it.locked }) { + pendingPress = null + notifyHudLocked() + return@safePointerEvent + } UiSounds.play(UiSoundEvent.HUD_DRAG_START) val movable = selectedHuds.filterTo(LinkedHashSet()) { !it.locked } // grabbing an unselected HUD drags the whole fused shape while grabbing an already selected @@ -1708,15 +1934,22 @@ fun HudDesignStudio(visible: Boolean = true) { resizedHud?.unpinCorner() isResizing = false resizedHud = null + groupScaling = null resizeCorner = null resizeStartBounds = null } return@safePointerEvent } - val hit = resizedHud ?: return@safePointerEvent val corner = resizeCorner ?: return@safePointerEvent val bounds = resizeStartBounds ?: return@safePointerEvent val s = Platform.screen().screenToMcScale() + val scaling = groupScaling + if (scaling != null) { + val factor = cornerScaleFactor(corner, bounds, pos.x * s, pos.y * s) + Snapshot.withMutableSnapshot { scaling.apply(factor) } + return@safePointerEvent + } + val hit = resizedHud ?: return@safePointerEvent Snapshot.withMutableSnapshot { resizeHud( hud = hit, @@ -1756,10 +1989,8 @@ fun HudDesignStudio(visible: Boolean = true) { gridEnabled = HudEditorViewport.gridEnabled, ) Snapshot.withMutableSnapshot { - for (member in draggedGroup) { - val ms = starts[member] ?: continue - member.setAbsolutePosition(ms.first + dx, ms.second + dy) - } + // only the HUDs holding their own place move, the ones anchored to them follow on their own + HudGroups.moveFrom(draggedGroup, starts, dx, dy) } } else if (marqueeActive) { if (event.changes.none { it.pressed }) { @@ -1793,18 +2024,23 @@ fun HudDesignStudio(visible: Boolean = true) { val hovered = hoveredHud // over the bar or on the way to it the hover must stick even over another HUD or the // icons swap out from under the cursor - val overActionBar = hovered?.let { hh -> - hitTestHudActionBarZone(hh, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) + val hoveredBar = hovered?.let { hoverBar(it, mcToScreen) } + val overActionBar = hoveredBar?.let { (bounds, bar) -> + hitTestHudActionBarZone(bounds, bar, pos.x, pos.y, mcToScreen) } == true - val inExpandedZone = hovered?.let { hh -> - hitTestHudWithActionBar(hh, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) + val inExpandedZone = hoveredBar?.let { (bounds, bar) -> + hitTestHudWithActionBar(bounds, bar, pos.x, pos.y, mcToScreen) } == true - hoveredHud = when { + val next = when { overActionBar -> hovered + // moving between HUDs of the same group keeps the group's bar where it is + hit != null && hovered != null && hit !== hovered && + unitGroupOf(hit, enteredGroup)?.let { it == unitGroupOf(hovered, enteredGroup) } == true -> hovered hit != null -> hit inExpandedZone -> hovered else -> null } + if (next !== hoveredHud) hoveredHud = next } } .safePointerEvent(PointerEventType.Release) { event -> @@ -1823,13 +2059,14 @@ fun HudDesignStudio(visible: Boolean = true) { wasResizedHud?.unpinCorner() isResizing = false resizedHud = null + groupScaling = null resizeCorner = null resizeStartBounds = null } if (wasResizing) { UiSounds.play(UiSoundEvent.HUD_RESIZE_END) Snapshot.withMutableSnapshot { - if (wasResizedHud != null) selectedHuds = setOf(wasResizedHud) + if (wasResizedHud != null) selectedHuds = unitOf(wasResizedHud, enteredGroup) closeLibrary() } return@safePointerEvent @@ -1840,7 +2077,7 @@ fun HudDesignStudio(visible: Boolean = true) { if (!click.wasSelected || narrowsSelection) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { pendingPress = null - if (narrowsSelection) selectedHuds = setOf(click.hud) + if (narrowsSelection) selectedHuds = click.unit } return@safePointerEvent } @@ -1864,14 +2101,14 @@ fun HudDesignStudio(visible: Boolean = true) { val pos = event.changes.firstOrNull()?.position ?: return@safePointerEvent if (inChrome(pos.x, pos.y)) return@safePointerEvent val mcToScreen = Platform.screen().mcToScreenScale() - if (hoveredHud?.let { hitTestHudActionBar(it, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) } == true) { + if (hoveredHud?.let { hoverBar(it, mcToScreen)?.second?.contains(pos.x, pos.y) } == true) { return@safePointerEvent } val hit = pickHudAt(pos.x, pos.y, primaryHud()) if (hit != null) { if (hit !in selectedHuds) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { - selectedHuds = setOf(hit) + selectedHuds = unitOf(hit, enteredGroup) closeLibrary() } } @@ -1880,7 +2117,7 @@ fun HudDesignStudio(visible: Boolean = true) { Snapshot.withMutableSnapshot { // a shape dragged as a whole leaves only the grabbed HUD selected so grabbing it again // pulls that one out instead of moving the shape a second time - if (wasCluster) selectedHuds = setOf(wasDraggedHud) + if (wasCluster) selectedHuds = unitOf(wasDraggedHud, enteredGroup) else if (wasDraggedGroup.isNotEmpty()) selectedHuds = wasDraggedGroup closeLibrary() } @@ -2072,6 +2309,15 @@ fun HudDesignStudio(visible: Boolean = true) { } return@onKeyEvent true } + // escape climbs out of a group the user stepped into one level at a time, selecting it + enteredGroup?.takeIf { keyEvent.key == Key.Escape }?.let { entered -> + val member = entered.huds.firstOrNull() + Snapshot.withMutableSnapshot { + enteredGroup = entered.parent + if (member != null) selectedHuds = unitOf(member, entered.parent) + } + return@onKeyEvent true + } if (keyEvent.key == Key.Escape && (selectedHuds.isNotEmpty() || panelOpen)) { Snapshot.withMutableSnapshot { selectedHuds = emptySet() @@ -2105,7 +2351,8 @@ fun HudDesignStudio(visible: Boolean = true) { } lastNudgeAt[0] = System.currentTimeMillis() Snapshot.withMutableSnapshot { - for (hud in selectedHuds) { + // HUDs anchored to another selected HUD follow it, so nudging them too would move them twice + for (hud in HudGroups.roots(selectedHuds)) { hud.setAbsolutePosition(hud.x + dx, hud.y + dy) } } @@ -2122,6 +2369,9 @@ fun HudDesignStudio(visible: Boolean = true) { drawContent() val mcToScreen = Platform.screen().mcToScreenScale() if (HudEditorViewport.gridEnabled) drawHudGrid(HUD_GRID_SIZE, mcToScreen) + val selectedGroups = selectedGroupsIn(selectedHuds, enteredGroup) + val soleGroup = soleGroup() + val hoveredGroup = hoveredHud?.let { unitGroupOf(it, enteredGroup) } for (hud in HudManager.activeInstances) { val bounds = hudBounds(hud) ?: continue val resizable = hud.resizeAxes != HudResize.None @@ -2131,7 +2381,8 @@ fun HudDesignStudio(visible: Boolean = true) { val sh = bounds.height * mcToScreen val isSelected = hud in selectedHuds val isPrimary = hud === primaryHud() - val isHovered = hud === hoveredHud + // a hovered group gets one box around it rather than a box on the HUD under the cursor + val isHovered = hud === hoveredHud && hoveredGroup == null // every HUD carried by the drag reads as being dragged so a fused shape shows the // whole shape moving val isBeingDragged = isDragging && (hud === draggedHud || hud in draggedGroup) @@ -2143,6 +2394,23 @@ fun HudDesignStudio(visible: Boolean = true) { } } + if (!hud.locked && selectedGroups.any { hud in it }) { + if (isBeingDragged) { + drawRect( + color = selectionBlue.copy(alpha = 0.10f), + topLeft = Offset(sx, sy), + size = Size(sw, sh), + ) + } + drawRect( + color = groupMemberBoxColor, + topLeft = Offset(sx, sy), + size = Size(sw, sh), + style = Stroke(width = 1f), + ) + continue + } + if (hud.locked) { // no selection handles because a locked HUD can be neither moved nor resized if (isSelected || isHovered) { @@ -2194,6 +2462,36 @@ fun HudDesignStudio(visible: Boolean = true) { ) } } + enteredGroup?.let { boundsOf(it.huds) }?.let { b -> + drawRect( + color = enteredGroupBoxColor, + topLeft = Offset(b.x * mcToScreen, b.y * mcToScreen), + size = Size(b.width * mcToScreen, b.height * mcToScreen), + style = Stroke(width = 1f, pathEffect = groupHoverDashEffect), + ) + } + if (hoveredGroup != null && hoveredGroup !in selectedGroups) { + boundsOf(hoveredGroup.huds)?.let { b -> + drawRect( + color = hoveredHudBoxColor, + topLeft = Offset(b.x * mcToScreen, b.y * mcToScreen), + size = Size(b.width * mcToScreen, b.height * mcToScreen), + style = Stroke(width = 1f, pathEffect = groupHoverDashEffect), + ) + } + } + for (group in selectedGroups) { + val members = group.huds + val b = boundsOf(members) ?: continue + val alone = group == soleGroup + drawSelectedHudBounds( + b, + mcToScreen, + badgeRadius = badgeRadius, + showHandles = alone && members.none { it.locked }, + showBadge = alone, + ) + } // hovering an anchored HUD traces the link back to the point it hangs off hoveredHud?.let { hovered -> val parent = hovered.effectiveAnchorParent @@ -2302,19 +2600,32 @@ fun HudDesignStudio(visible: Boolean = true) { // hover-only or the bar would show for every locked HUD at once and linger over a selected HUD val actionBarTarget = hoveredHud - if (actionBarTarget != null) { - key(actionBarTarget) { + val actionBar = actionBarTarget?.let { hoverBar(it, Platform.screen().mcToScreenScale()) } + if (actionBarTarget != null && actionBar != null && actionBar.first.width > 0f && actionBar.first.height > 0f) { + val unit = unitOf(actionBarTarget, enteredGroup) + val groupAction = groupActionFor(actionBarTarget) + key(actionBarTarget, groupAction) { HudActionBar( - hud = actionBarTarget, - mcToScreen = Platform.screen().mcToScreenScale(), + huds = unit.toList(), + layout = actionBar.second, densityFloat = densityFloat, actionIconPx = actionIconPx, actionBarGapPx = actionBarGapPx, chromeAlpha = chromeAlpha, - onDelete = { deleteHuds(listOf(actionBarTarget)) }, + groupAction = groupAction, + onGroupAction = { + Snapshot.withMutableSnapshot { + when (groupAction) { + GroupAction.Group -> groupSelection() + GroupAction.Ungroup -> unitGroupOf(actionBarTarget, enteredGroup)?.let { ungroup(listOf(it)) } + null -> {} + } + } + }, + onDelete = { deleteHuds(unit) }, ) { Snapshot.withMutableSnapshot { - selectedHuds = setOf(actionBarTarget) + selectedHuds = unit panelOpen = true } } @@ -2330,6 +2641,10 @@ fun HudDesignStudio(visible: Boolean = true) { isDragging -> null lockedPressHud != null || hoveredHud?.locked == true -> "This HUD is locked · Use the lock button above it to move or resize it" + hoveredHud?.let { !it.locked && unitGroupOf(it, enteredGroup) != null } == true -> + "Drag to move the group · Drag a corner to scale it · Double-click to edit inside it" + enteredGroup != null && hoveredHud == null -> + "Editing inside a group · Esc to step out" hoveredHud?.let { it.bgMerged && !it.locked } == true -> "Drag to move the merged HUDs together · Click it first, then drag, to pull it out" else -> null @@ -2554,6 +2869,17 @@ fun HudDesignStudio(visible: Boolean = true) { if (hud in selectedHuds) selectedHuds.toList() else listOf(hud) } ?: emptyList() + val menuTargetSet = contextMenuTargets.toSet() + val menuGroups = if (hudContextMenuTarget != null) selectedGroupsIn(menuTargetSet, enteredGroup) else emptyList() + val menuUnits = if (hudContextMenuTarget != null) selectionUnits(menuTargetSet, enteredGroup) else emptyList() + val menuLoose = contextMenuTargets.filter { hud -> menuGroups.none { hud in it } } + val menuInGroup = menuGroups.any { it.parent != null } || menuLoose.any { it.group != null } + // a group already holding every target directly is no place to add them, and a group can never go inside itself + val addToTargets = if (hudContextMenuTarget == null) emptyList() else HudGroups.all().filter { target -> + menuGroups.none { it == target || target in it } && + !(menuLoose.all { it.groupPath == target.path } && menuGroups.all { it.parent == target }) + }.take(8) + val menuOpen = hudContextMenuTarget != null || pasteMenuOffset != null val canUndo = remember(menuOpen) { menuOpen && history.canUndo } val canRedo = remember(menuOpen) { menuOpen && history.canRedo } @@ -2564,14 +2890,39 @@ fun HudDesignStudio(visible: Boolean = true) { offset = hudContextMenuOffset, onDismiss = { hudContextMenuTarget = null }, targets = contextMenuTargets, - settingsEnabled = contextMenuTargets.size <= 1, + settingsEnabled = menuUnits.size <= 1, onSettings = { hud -> Snapshot.withMutableSnapshot { hudContextMenuTarget = null - selectedHuds = setOf(hud) + selectedHuds = unitOf(hud, enteredGroup) panelOpen = true } }, + units = menuUnits, + onGroup = if (menuUnits.size >= 2) { + { Snapshot.withMutableSnapshot { if (HudGroups.group(menuLoose, menuGroups) != null) UiSounds.play(UiSoundEvent.CLICK) } } + } else null, + onUngroup = if (menuGroups.isNotEmpty()) { + { Snapshot.withMutableSnapshot { ungroup(menuGroups) } } + } else null, + onRemoveFromGroup = if (menuInGroup) { + { + Snapshot.withMutableSnapshot { + menuLoose.forEach(HudGroups::removeFromGroup) + menuGroups.forEach(HudGroups::removeFromGroup) + // the targets may have just left the group the user stepped into + if (enteredGroup?.let { entered -> contextMenuTargets.none { it in entered } } == true) enteredGroup = null + } + UiSounds.play(UiSoundEvent.CLICK) + } + } else null, + addToGroups = addToTargets.map { target -> + val add: () -> Unit = { + Snapshot.withMutableSnapshot { HudGroups.addTo(target, menuLoose, menuGroups) } + UiSounds.play(UiSoundEvent.CLICK) + } + hudGroupLabel(target) to add + }, onCopy = { _ -> Snapshot.withMutableSnapshot { hudClipboard = contextMenuTargets } UiSounds.play(UiSoundEvent.CLICK) @@ -2837,8 +3188,10 @@ internal fun duplicateHudGroup( } else { Offset(minX + 32f, minY + 32f) } + // copying a whole group makes a new group of the copies instead of adding them to the original + val groupPaths = HudGroups.pathsForCopies(viable) val pasted = mutableListOf() - for (hud in viable) { + for ((i, hud) in viable.withIndex()) { if (!canDuplicateHud(hud)) continue val bounds = hudBounds(hud) ?: continue val dx = bounds.x - minX @@ -2851,6 +3204,7 @@ internal fun duplicateHudGroup( val nx = (target.x + dx).coerceIn(0f, maxOf(0f, sw - w)) val ny = (target.y + dy).coerceIn(0f, maxOf(0f, sh - h)) Snapshot.withMutableSnapshot { + fresh.groupPath = groupPaths[i] fresh.setAbsolutePosition(nx, ny) } pasted.add(fresh) @@ -2870,7 +3224,9 @@ fun HudDragLayer(modifier: Modifier = Modifier) { var dragOffsetX by remember { mutableStateOf(0f) } var dragOffsetY by remember { mutableStateOf(0f) } val dragSnapper = remember { HudDragSnapper() } - var dragStart by remember { mutableStateOf(Offset.Zero) } + // a grouped HUD carries its whole group so the group keeps its shape + var dragSet by remember { mutableStateOf>(emptySet()) } + var dragStarts by remember { mutableStateOf>>(emptyMap()) } var lockedPressHud by remember { mutableStateOf(null) } var lockedPressOrigin by remember { mutableStateOf(Offset.Zero) } @@ -2885,6 +3241,8 @@ fun HudDragLayer(modifier: Modifier = Modifier) { isDragging = false draggedHud = null dragSnapper.end() + dragSet = emptySet() + dragStarts = emptyMap() shellLayout.markHudDragging(false) } dropped?.onEditorDragEnd() @@ -2910,8 +3268,9 @@ fun HudDragLayer(modifier: Modifier = Modifier) { } val s = Platform.screen().screenToMcScale() val hit = orderedInstances().lastOrNull { !it.locked && hitTestHud(it, pos.x, pos.y) } - if (hit == null) { - orderedInstances().lastOrNull { it.locked && hitTestHud(it, pos.x, pos.y) }?.let { + val unit = hit?.let { unitOf(it, null) } + if (hit == null || unit == null || unit.any { it.locked }) { + (hit ?: orderedInstances().lastOrNull { it.locked && hitTestHud(it, pos.x, pos.y) })?.let { Snapshot.withMutableSnapshot { lockedPressHud = it lockedPressOrigin = pos @@ -2925,9 +3284,10 @@ fun HudDragLayer(modifier: Modifier = Modifier) { Snapshot.withMutableSnapshot { dragOffsetX = pos.x * s - hit.x dragOffsetY = pos.y * s - hit.y - dragStart = Offset(hit.x, hit.y) isDragging = true draggedHud = hit + dragSet = unit + dragStarts = unit.associateWith { it.x to it.y } hoveredHud = hit shellLayout.markHudDragging(true) } @@ -2941,16 +3301,17 @@ fun HudDragLayer(modifier: Modifier = Modifier) { } val s = Platform.screen().screenToMcScale() val hit = draggedHud ?: return@safePointerEvent - val start = dragStart + val start = dragStarts[hit] ?: return@safePointerEvent + beginHudSnap(dragSnapper, dragSet, hit, dragStarts, refresh = true) val (dx, dy) = dragSnapper.move( - rawDx = pos.x * s - dragOffsetX - start.x, - rawDy = pos.y * s - dragOffsetY - start.y, + rawDx = pos.x * s - dragOffsetX - start.first, + rawDy = pos.y * s - dragOffsetY - start.second, threshold = SNAP_DISTANCE_PX * s, snapping = !event.keyboardModifiers.isAltPressed, gridEnabled = HudEditorViewport.gridEnabled, ) Snapshot.withMutableSnapshot { - hit.setAbsolutePosition(start.x + dx, start.y + dy) + HudGroups.moveFrom(dragSet, dragStarts, dx, dy) } } else { if (lockedPressHud != null) { @@ -3056,8 +3417,8 @@ fun HudDragLayer(modifier: Modifier = Modifier) { // icons since IconButton reads LocalTheme if (bounds != null && bounds.width > 0f && bounds.height > 0f && layout != null) Theme { HudActionBar( - hud = actionBarTarget, - mcToScreen = mcToScreen, + huds = listOf(actionBarTarget), + layout = layout, densityFloat = densityFloat, actionIconPx = actionIconPx, actionBarGapPx = actionBarGapPx, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt index 2f4b28e0e..fb664ebb8 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt @@ -37,6 +37,8 @@ private val MinecraftIconOverrides = mapOf( "info" to "/assets/oneconfig/ico/minecraft/info.svg", "italic" to "/assets/oneconfig/ico/minecraft/italic.svg", "keyboard" to "/assets/oneconfig/ico/minecraft/keyboard.svg", + "group" to "/assets/oneconfig/ico/minecraft/group.svg", + "ungroup" to "/assets/oneconfig/ico/minecraft/ungroup.svg", "layers" to "/assets/oneconfig/ico/minecraft/layers.svg", "left-arrow" to "/assets/oneconfig/ico/minecraft/left-arrow.svg", "lightning-01" to "/assets/oneconfig/ico/minecraft/lightning-01.svg", diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/group.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/group.svg new file mode 100644 index 000000000..19db24a36 --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/group.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/group.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/group.svg new file mode 100644 index 000000000..578dbd05b --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/group.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/ungroup.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/ungroup.svg new file mode 100644 index 000000000..eab188dd6 --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/ungroup.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/ungroup.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/ungroup.svg new file mode 100644 index 000000000..705a88169 --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/ungroup.svg @@ -0,0 +1,4 @@ + + + + From 50e1ccded0ae32456d43acea6425f81680e542ee Mon Sep 17 00:00:00 2001 From: Tellinq <59412384+Tellinq@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:32:41 -0500 Subject: [PATCH 2/2] feat(hud): add shared group settings and keep member locks inside their group --- modules/hud/api/hud.api | 13 + .../org/polyfrost/oneconfig/api/hud/v1/Hud.kt | 5 + .../oneconfig/api/hud/v1/HudGroups.kt | 140 +++++- .../oneconfig/api/hud/v1/HudGroupsTest.java | 121 ++++- .../ui/components/settings/DropdownOption.kt | 12 +- .../ui/components/settings/NumberOption.kt | 14 +- .../ui/components/settings/SwitchControl.kt | 17 +- .../internal/ui/hud/HudSettingsReset.kt | 11 +- .../ui/hud/screens/HudDesignSession.kt | 9 +- .../ui/hud/screens/HudDesignStudio.kt | 286 ++++++++---- .../internal/ui/hud/screens/HudGroupLabels.kt | 25 + .../internal/ui/hud/screens/HudHistory.kt | 4 +- .../internal/ui/hud/screens/sections/Cards.kt | 13 +- .../ui/hud/screens/sections/GroupSettings.kt | 70 +++ .../ui/hud/screens/sections/Layout.kt | 4 +- .../ui/hud/screens/sections/SharedSettings.kt | 424 +++++++++++++++++ .../screens/sections/SharedSettingsModel.kt | 427 ++++++++++++++++++ .../internal/ui/hud/screens/sections/Style.kt | 91 ++-- .../internal/ui/screens/ConfigScreen.kt | 26 +- .../ui/hud/screens/HudHistoryTest.java | 58 +++ 20 files changed, 1587 insertions(+), 183 deletions(-) create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudGroupLabels.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/GroupSettings.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettings.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettingsModel.kt diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index 961eaddf6..4a47b9ebc 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -275,10 +275,13 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudGroup { public final fun getDirectHuds ()Ljava/util/List; public final fun getHuds ()Ljava/util/List; public final fun getId ()Ljava/lang/String; + public final fun getLocked ()Z public final fun getName ()Ljava/lang/String; public final fun getParent ()Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; public final fun getPath ()Ljava/lang/String; + public final fun getScale ()F public final fun getSubGroups ()Ljava/util/List; + public final fun getUniformScale ()Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -298,6 +301,9 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudGroups { public static final fun group (Ljava/util/Collection;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; public static final fun group (Ljava/util/Collection;Ljava/util/Collection;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; public static synthetic fun group$default (Ljava/util/Collection;Ljava/util/Collection;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; + public static final fun hasUniformScale (Ljava/util/Collection;)Z + public static final fun matchScale (Ljava/util/Collection;F)V + public static final fun matchScale (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;F)V public static final fun moveFrom (Ljava/util/Collection;Ljava/util/Map;FF)V public static final fun nameOf (Ljava/lang/String;)Ljava/lang/String; public static final fun of (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup; @@ -306,7 +312,14 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudGroups { public static final fun removeFromGroup (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)V public static final fun removeFromGroup (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;)V public static final fun roots (Ljava/util/Collection;)Ljava/util/List; + public static final fun scaleOf (Ljava/util/Collection;)F + public static final fun scaleRange (Ljava/util/Collection;)Lkotlin/ranges/ClosedFloatingPointRange; + public static final fun scaleRange (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;)Lkotlin/ranges/ClosedFloatingPointRange; public static final fun scaling (Ljava/util/Collection;FF)Lorg/polyfrost/oneconfig/api/hud/v1/HudGroups$Scaling; + public static final fun setLocked (Ljava/util/Collection;Z)V + public static final fun setLocked (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;Z)V + public static final fun setScale (Ljava/util/Collection;F)V + public static final fun setScale (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;F)V public static final fun ungroup (Lorg/polyfrost/oneconfig/api/hud/v1/HudGroup;)V } diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt index 0479d7238..8fd71214d 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt @@ -389,6 +389,11 @@ public abstract class Hud( return minSize } + /** Drops the size measured this frame for a caller which has just changed what it depends on */ + internal fun forgetMinimumSize() { + minSizeFrame = Long.MIN_VALUE + } + public open val scaledWidth: Float get() { val scale = effectiveScale val w = if (staticWidth || renderedW <= 0f) staticW * scale else renderedW diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt index 15a9d98f5..b18b9a200 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudGroups.kt @@ -66,6 +66,19 @@ public class HudGroup internal constructor( /** The live HUDs sitting directly in this group rather than in one of its sub-groups */ public val directHuds: List get() = HudManager.activeInstances.filter { it.groupPath == path } + /** + * Whether the whole group is locked in place, which is when every HUD in it is locked + * + * A HUD locked on its own only holds still inside the group and still moves with it + */ + public val locked: Boolean get() = huds.let { members -> members.isNotEmpty() && members.all { it.locked } } + + /** The largest [Hud.customScale] among the HUDs that can be scaled */ + public val scale: Float get() = HudGroups.scaleOf(huds) + + /** Whether every HUD that can be scaled is at the same [Hud.customScale] */ + public val uniformScale: Boolean get() = HudGroups.hasUniformScale(huds) + /** The groups nested directly in this one */ public val subGroups: List get() { val prefix = path + HudGroups.SEPARATOR @@ -91,6 +104,8 @@ public class HudGroup internal constructor( override fun toString(): String = "HudGroup($path)" } +private val HUD_SCALE_RANGE = 0.25f..4f + /** * Creates, changes and lays out [HudGroup]s * @@ -101,9 +116,6 @@ public class HudGroup internal constructor( public object HudGroups { public const val SEPARATOR: Char = '/' - private const val MIN_SCALE = 0.25f - private const val MAX_SCALE = 4f - /** Backstop for walking a chain of HUDs anchored to one another */ private const val MAX_ANCHOR_DEPTH = 16 @@ -213,6 +225,107 @@ public object HudGroups { move(listOf(Moving(group.huds, parent.path)), parent.parent?.path.orEmpty()) } + /** Locks or unlocks every HUD in [group] */ + @JvmStatic + public fun setLocked(group: HudGroup, locked: Boolean): Unit = setLocked(group.huds, locked) + + /** Locks or unlocks every HUD in [huds] */ + @JvmStatic + public fun setLocked(huds: Collection, locked: Boolean) { + Snapshot.withMutableSnapshot { for (hud in huds) hud.locked = locked } + } + + /** The largest [Hud.customScale] among the HUDs of [huds] that can be scaled */ + @JvmStatic + public fun scaleOf(huds: Collection): Float = scalable(huds).maxOfOrNull { it.customScale } ?: 1f + + /** Whether every HUD of [huds] that can be scaled is at the same [Hud.customScale] */ + @JvmStatic + public fun hasUniformScale(huds: Collection): Boolean = scalable(huds).map { it.customScale }.distinct().size <= 1 + + /** The values [HudGroup.scale] can take while every HUD stays inside its own [HUD_SCALE_RANGE] */ + @JvmStatic + public fun scaleRange(group: HudGroup): ClosedFloatingPointRange = scaleRange(group.huds) + + /** The values [scaleOf] can take for [huds] while every HUD stays inside its own [HUD_SCALE_RANGE] */ + @JvmStatic + public fun scaleRange(huds: Collection): ClosedFloatingPointRange { + val scaling = Scaling(huds, 0f, 0f) + val scale = scaleOf(huds) + return scale * scaling.minFactor..scale * scaling.maxFactor + } + + /** + * Sets [HudGroup.scale], scaling the whole group as one block so HUDs at different scales keep + * their proportions + * + * The group stays against the edge of the screen it sits nearest + */ + @JvmStatic + public fun setScale(group: HudGroup, scale: Float): Unit = setScale(group.huds, scale) + + /** + * Sets [scaleOf] for [huds], scaling them as one block so HUDs at different scales keep their + * proportions + * + * The block stays against the edge of the screen it sits nearest + */ + @JvmStatic + public fun setScale(huds: Collection, scale: Float) { + val current = scaleOf(huds) + val box = bounds(huds) ?: return + if (current <= 0f) return + val sw = HudManager.guiScreenWidth + val sh = HudManager.guiScreenHeight + Snapshot.withMutableSnapshot { + Scaling(huds, box[0] + box[2] * pivotFraction(box[0] + box[2] / 2f, sw), box[1] + box[3] * pivotFraction(box[1] + box[3] / 2f, sh)) + .apply(scale / current) + } + } + + /** Puts every HUD in [group] that can be scaled at the same [scale] */ + @JvmStatic + public fun matchScale(group: HudGroup, scale: Float): Unit = matchScale(group.huds, scale) + + /** Puts every HUD in [huds] that can be scaled at the same [scale] */ + @JvmStatic + public fun matchScale(huds: Collection, scale: Float) { + Snapshot.withMutableSnapshot { + for (hud in parentsFirst(scalable(huds), huds)) { + val previous = hud.customScale + hud.customScale = scale.coerceIn(HUD_SCALE_RANGE) + resized(hud, hud.renderedW, hud.renderedH, if (previous > 0f) hud.customScale / previous else 1f) + } + } + } + + private fun pivotFraction(center: Float, screen: Float): Float = when { + screen <= 0f || center < screen / GRID_SIZE -> 0f + center < screen * 2f / GRID_SIZE -> 0.5f + else -> 1f + } + + /** + * Tells [hud] the size it will have after its scale changed by [factor] from a box of [w] by [h] + * + * The laid out size only catches up on the next frame, so without this every anchor and growth + * point would be worked out against the old size + */ + private fun resized(hud: Hud, w: Float, h: Float, factor: Float) { + hud.forgetMinimumSize() + if (w > 0f) hud.renderedW = w * factor + if (h > 0f) hud.renderedH = h * factor + } + + /** + * The HUDs of [huds] the corner handles can resize + * + * The rest keep their size when their group scales and only move with it + */ + internal fun scalable(huds: Collection): List = huds.filter { + it.resizeAxes == HudResize.Both && (it as? LegacyHudMarker)?.supportsScale != false + } + /** * Groups the HUDs of [members] under [id] the first time they are all loaded together * @@ -322,6 +435,7 @@ public object HudGroups { public class Scaling internal constructor(huds: Collection, private val pivotX: Float, private val pivotY: Float) { private val members: List + private val scales: BooleanArray private val startScale: FloatArray private val startX: FloatArray private val startY: FloatArray @@ -335,6 +449,10 @@ public object HudGroups { init { // anchor parents first so every HUD is placed against where its parent already ended up members = parentsFirst(huds, huds) + scales = BooleanArray(members.size).also { flags -> + val can = identitySet(scalable(members)) + for (i in members.indices) flags[i] = members[i] in can + } startScale = FloatArray(members.size) { members[it].customScale } startX = FloatArray(members.size) { members[it].x } startY = FloatArray(members.size) { members[it].y } @@ -342,10 +460,12 @@ public object HudGroups { startH = FloatArray(members.size) { members[it].renderedH } var lo = 0f var hi = Float.POSITIVE_INFINITY - for (s in startScale) { - if (s <= 0f) continue - lo = maxOf(lo, MIN_SCALE / s) - hi = minOf(hi, MAX_SCALE / s) + for (i in members.indices) { + val s = startScale[i] + if (!scales[i] || s <= 0f) continue + val range = HUD_SCALE_RANGE + lo = maxOf(lo, range.start / s) + hi = minOf(hi, range.endInclusive / s) } minFactor = lo.coerceAtMost(1f) maxFactor = if (hi.isInfinite()) 1f else hi.coerceAtLeast(1f) @@ -354,13 +474,11 @@ public object HudGroups { /** Scales everything to [factor] times its starting size and returns the factor actually used */ public fun apply(factor: Float): Float { val f = factor.coerceIn(minFactor, maxFactor) - // the laid out size only catches up on the next frame, so set it now or every anchor and - // growth point below would be worked out against the old size for (i in members.indices) { + if (!scales[i]) continue val hud = members[i] hud.customScale = startScale[i] * f - if (startW[i] > 0f) hud.renderedW = startW[i] * f - if (startH[i] > 0f) hud.renderedH = startH[i] * f + resized(hud, startW[i], startH[i], f) } for (i in members.indices) { members[i].setAbsolutePosition(pivotX + (startX[i] - pivotX) * f, pivotY + (startY[i] - pivotY) * f) diff --git a/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java b/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java index 15f8de89a..4b373f70d 100644 --- a/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java +++ b/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudGroupsTest.java @@ -42,6 +42,7 @@ static class DefaultTwo extends BoxHud { DefaultTwo() { super("group-default-two private Hud b; private Hud c; + @SuppressWarnings("unchecked") @BeforeEach void setUp() throws Exception { ConfigManager.active(); @@ -61,6 +62,10 @@ void setUp() throws Exception { hud.setGroupPath(""); hud.clearAnchor(); hud.setCustomScale(1f); + hud.setCustomScale(1f); + hud.setLocked(false); + hud.setRenderedW(40f); + hud.setRenderedH(10f); } a.setAbsolutePosition(100f, 100f); b.setAbsolutePosition(140f, 100f); @@ -232,7 +237,9 @@ void scalingAGroupScalesSizesAndGaps() { float used = scaling.apply(2f); assertEquals(2f, used, 0.001f); - for (Hud hud : group.getHuds()) assertEquals(2f, hud.getCustomScale(), 0.001f); + for (Hud hud : group.getHuds()) { + assertEquals(2f, hud.getCustomScale(), 0.001f, "the group scales the value the corner handles change"); + } assertEquals(100f, a.getX(), 0.001f, "the pivot stays put"); assertEquals(100f, a.getY(), 0.001f); assertEquals(80f, a.getScaledWidth(), 0.001f); @@ -252,13 +259,117 @@ void scalingAGroupScalesSizesAndGaps() { @Test void scalingStopsAtTheScaleLimits() { + float max = 4f; + float min = 0.25f; a.setCustomScale(2f); HudGroups.Scaling scaling = HudGroups.scaling(Arrays.asList(a, b), 100f, 100f); - assertEquals(2f, scaling.getMaxFactor(), 0.001f, "a is already at 2x so the group can only double"); - assertEquals(2f, scaling.apply(10f), 0.001f); - assertEquals(4f, a.getCustomScale(), 0.001f); + assertEquals(max / 2f, scaling.getMaxFactor(), 0.001f, "a is already at 2x so it reaches the limit first"); + assertEquals(max / 2f, scaling.apply(10f), 0.001f); + assertEquals(max, a.getCustomScale(), 0.001f); + assertEquals(max / 2f, b.getCustomScale(), 0.001f, "the group keeps its proportions at the limit"); + assertEquals(min, scaling.getMinFactor(), 0.001f, "b at 1x reaches the lower limit first"); + } + + @Test + void groupScaleKeepsProportionsAndMatchEnforcesOne() { + a.setCustomScale(4f); + b.setCustomScale(2f); + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + assertEquals(4f, group.getScale(), 0.001f); + assertFalse(group.getUniformScale()); + + HudGroups.setScale(group, 2f); + assertEquals(2f, a.getCustomScale(), 0.001f); + assertEquals(1f, b.getCustomScale(), 0.001f, "the ratio between them survives"); + + HudGroups.matchScale(group, 2f); assertEquals(2f, b.getCustomScale(), 0.001f); - assertEquals(0.25f, scaling.getMinFactor(), 0.001f); + assertTrue(group.getUniformScale()); + } + + @Test + void groupScaleHoldsTheEdgeTheGroupSitsNearest() { + a.setAbsolutePosition(460f, 500f); + b.setAbsolutePosition(500f, 500f); + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + + HudGroups.setScale(group, 2f); + float[] box = HudGroups.bounds(group.getHuds()); + assertNotNull(box); + assertEquals(510f, box[1] + box[3], 0.001f, "a group along the bottom keeps its bottom edge"); + assertEquals(500f, box[0] + box[2] / 2f, 0.001f, "and stays centred"); + } + + @Test + void aGroupIsOnlyLockedWhenEveryHudIs() { + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + a.setLocked(true); + assertFalse(group.getLocked(), "one locked HUD does not hold the group"); + + Map> starts = new HashMap<>(); + for (Hud hud : group.getHuds()) starts.put(hud, new Pair<>(hud.getX(), hud.getY())); + HudGroups.moveFrom(group.getHuds(), starts, 10f, 0f); + assertEquals(110f, a.getX(), 0.001f, "the locked HUD moves with its group"); + + HudGroups.setLocked(group, true); + assertTrue(group.getLocked()); + HudGroups.setLocked(group, false); + assertFalse(a.getLocked(), "unlocking the group frees the HUD locked on its own too"); + } + + @Test + void looseHudsScaleThroughTheSameCodeAsAGroup() { + a.setCustomScale(4f); + b.setCustomScale(2f); + List loose = Arrays.asList(a, b); + assertEquals(4f, HudGroups.scaleOf(loose), 0.001f, "the biggest scale stands for the selection"); + assertFalse(HudGroups.hasUniformScale(loose)); + + HudGroups.setScale(loose, 2f); + assertEquals(2f, a.getCustomScale(), 0.001f); + assertEquals(1f, b.getCustomScale(), 0.001f, "the ratio between them survives"); + + HudGroups.matchScale(loose, 2f); + assertEquals(2f, b.getCustomScale(), 0.001f); + assertTrue(HudGroups.hasUniformScale(loose)); + assertEquals(1f, c.getCustomScale(), 0.001f, "a HUD outside the selection is left alone"); + } + + @Test + void groupScaleCodeDelegatesToTheLooseOverloads() { + a.setCustomScale(4f); + b.setCustomScale(2f); + HudGroup group = HudGroups.group(Arrays.asList(a, b)); + assertNotNull(group); + + assertEquals(HudGroups.scaleOf(group.getHuds()), group.getScale(), 0.001f); + assertEquals(HudGroups.hasUniformScale(group.getHuds()), group.getUniformScale()); + assertEquals(HudGroups.scaleRange(group.getHuds()), HudGroups.scaleRange(group)); + } + + @Test + void looseScaleRangeStopsAtTheFirstHudToReachALimit() { + float max = 4f; + a.setCustomScale(2f); + List loose = Arrays.asList(a, b); + assertEquals(max, HudGroups.scaleRange(loose).getEndInclusive(), 0.001f, "the biggest HUD reaches the limit first"); + assertEquals(2f, HudGroups.scaleOf(loose), 0.001f); + assertTrue(HudGroups.scaleRange(loose).getStart() < 2f); + } + + @Test + void looseHudsLockTogether() { + List loose = Arrays.asList(a, b); + HudGroups.setLocked(loose, true); + assertTrue(a.getLocked()); + assertTrue(b.getLocked()); + assertFalse(c.getLocked(), "a HUD outside the selection stays free"); + HudGroups.setLocked(loose, false); + assertFalse(a.getLocked()); + assertFalse(b.getLocked()); } @Test diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt index dcd4da7c2..5a10f36b3 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt @@ -79,11 +79,17 @@ fun DropdownOption(data: DropdownOptionData) { /** [options] can be anything [Text] can show, such as translated text */ @Composable -fun Dropdown(options: List, selected: Int, onSelect: (Int) -> Unit, width: Dp = LocalOptionWidth.current) { +fun Dropdown( + options: List, + selected: Int, + onSelect: (Int) -> Unit, + width: Dp = LocalOptionWidth.current, + mixed: Boolean = false, +) { var expanded by remember { mutableStateOf(false) } - DropdownFrame(options.getOrElse(selected) { "—" }, expanded, { expanded = it }, width) { + DropdownFrame(if (mixed) "Mixed" else options.getOrElse(selected) { "—" }, expanded, { expanded = it }, width) { options.forEachIndexed { index, option -> - DropdownItem(option, selected = index == selected) { + DropdownItem(option, selected = !mixed && index == selected) { onSelect(index) expanded = false } 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 1440636d3..10f353941 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 @@ -261,6 +261,10 @@ fun SpinnerArrows(value: Float, min: Float, max: Float, step: Float, onStep: (Fl } } +/** + * @param mixed reads "Mixed" in place of [value] until the field is focused, for a setting that differs + * between several things + */ @Composable fun NumberSpinner( value: Float, @@ -281,11 +285,17 @@ fun NumberSpinner( enabled: Boolean = true, onEditStart: (() -> Unit)? = null, liveValue: (() -> Float)? = null, + mixed: Boolean = false, ) { val theme = LocalTheme.current val focusManager = LocalFocusManager.current var focused by remember { mutableStateOf(false) } - val formatted = if (format != null && !focused) format(value) else null + val showMixed = mixed && !focused + val formatted = when { + showMixed -> "Mixed" + format != null && !focused -> format(value) + else -> null + } val textState = remember(if (math && focused) null else value) { mutableStateOf(formatSpinnerValue(value, step)) } var text by textState var edited by remember { mutableStateOf(false) } @@ -405,7 +415,7 @@ fun NumberSpinner( Box { Box(Modifier.alpha(if (formatted == null) 1f else 0f)) { innerTextField() } if (formatted != null) { - Text(formatted, color = theme.textColor, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(formatted, color = if (showMixed) theme.textColorSecondary else theme.textColor, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt index 494298395..48a49792b 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt @@ -30,13 +30,20 @@ import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +/** + * @param mixed shows the switch halfway between on and off for a setting that differs between several + * things, and a click then turns it on for all of them + */ @Composable -fun SwitchControl(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { +fun SwitchControl(checked: Boolean, onCheckedChange: (Boolean) -> Unit) = SwitchControl(checked, false, onCheckedChange) + +@Composable +fun SwitchControl(checked: Boolean, mixed: Boolean, onCheckedChange: (Boolean) -> Unit) { val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() - val accentAlpha = animateFloatAsState(if (checked) 1f else 0f) - val thumbOffset = animateDpAsState(if (checked) 24.dp else 3.dp, animationSpec = ReducedMotion.spec(spring())) + val accentAlpha = animateFloatAsState(if (mixed) 0.5f else if (checked) 1f else 0f) + val thumbOffset = animateDpAsState(if (mixed) 13.5.dp else if (checked) 24.dp else 3.dp, animationSpec = ReducedMotion.spec(spring())) val borderColor = animateColorAsState( if (isHovered) LocalTheme.current.textColor.copy(alpha = 0.15f) else Color.Transparent ) @@ -45,7 +52,7 @@ fun SwitchControl(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(if (checked) "On" else "Off", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + Text(if (mixed) "Mixed" else if (checked) "On" else "Off", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) Box( modifier = Modifier .size(42.dp, 21.dp) @@ -53,7 +60,7 @@ fun SwitchControl(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { .clip(LocalTheme.current.circleShape) .background(LocalTheme.current.controlTrackColor) .animatedFrame(LocalTheme.current.circleShape, accentAlpha = { accentAlpha.value }, border = { borderColor.value }) - .onClick(interactionSource) { onCheckedChange(!checked) } + .onClick(interactionSource) { onCheckedChange(mixed || !checked) } .pointerHoverIcon(PointerIcon.Hand), ) { Box( diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt index cd74108b8..c08cb8c86 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt @@ -317,6 +317,15 @@ fun HudSettingsContent(hud: Hud, content: @Composable () -> Unit) { } } +/** [HudSettingsContent] for a panel editing several HUDs at once, which is recreated when any one of them is */ +@Composable +fun HudSettingsContent(huds: Collection, content: @Composable () -> Unit) { + val list = huds.toList() + key(list, list.map(::hudUiEpoch)) { + content() + } +} + private val DeleteMenuColor = Color(0xFFE5484D) /** @@ -333,7 +342,6 @@ fun HudCanvasResetMenu( onDismiss: () -> Unit, targets: List = emptyList(), onSettings: (Hud) -> Unit = {}, - settingsEnabled: Boolean = true, onCopy: (Hud) -> Unit = {}, onCut: (Hud) -> Unit = {}, onPaste: (Hud) -> Unit = {}, @@ -398,7 +406,6 @@ fun HudCanvasResetMenu( HudMenuRow( icon = "settings", text = "Settings", - enabled = settingsEnabled, hint = settingsHint, ) { onSettings(hud) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignSession.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignSession.kt index b7d567505..f34ffd520 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignSession.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignSession.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.snapshots.Snapshot import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.hud.v1.HudGroup import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.internal.ui.hud.resetAllHudProperties @@ -38,6 +39,10 @@ object HudDesignSession { /** The HUDs currently selected in the design studio so global keybinds can act on them */ var activeSelection: List by mutableStateOf(emptyList()) + /** The group selected on its own in the design studio, if any */ + @Volatile + var activeGroup: HudGroup? = null + private val commandQueue = Channel(Channel.UNLIMITED) /** @@ -78,8 +83,8 @@ object HudDesignSession { if (!pressed) return false if (!HudManager.isEditorOpen) return false val huds = activeSelection - if (huds.size != 1) return false - if (huds.first().locked) return false + if (huds.isEmpty()) return false + if (activeGroup == null && huds.size == 1 && huds.first().locked) return false post(StudioCommand.OpenSettings) return true } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt index c3fd57921..5ae124247 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt @@ -108,8 +108,14 @@ import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreviewState import org.polyfrost.oneconfig.internal.ui.hud.components.rememberHudPreview import org.polyfrost.oneconfig.internal.ui.hud.modNameFor import org.polyfrost.oneconfig.internal.ui.hud.repairHudStaticSize +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.GroupSettings import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.Layout import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.Settings +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedLayout +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedOptions +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedSettings +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedStyle +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedTab import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.Style import org.polyfrost.oneconfig.internal.ui.screens.rememberHasHudOptions import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus @@ -381,13 +387,13 @@ internal enum class HudAlign(val icon: String, val label: String) { internal fun alignHuds(units: List>, align: HudAlign) { val boxes = units.mapNotNull { unit -> boundsOf(unit)?.let { unit to it } } if (boxes.isEmpty()) return - if (boxes.any { (unit, _) -> unit.any { it.locked } }) notifyHudLocked() + if (boxes.any { (unit, _) -> unit.all { it.locked } }) notifyHudLocked() val single = boxes.size == 1 val x0 = if (single) 0f else boxes.minOf { it.second.x } val y0 = if (single) 0f else boxes.minOf { it.second.y } val x1 = if (single) HudManager.guiScreenWidth else boxes.maxOf { it.second.x + it.second.width } val y1 = if (single) HudManager.guiScreenHeight else boxes.maxOf { it.second.y + it.second.height } - val moves = boxes.filter { (unit, _) -> unit.none { it.locked } }.map { (unit, b) -> + val moves = boxes.filter { (unit, _) -> !unit.all { it.locked } }.map { (unit, b) -> val dx = when (align) { HudAlign.Left -> x0 - b.x HudAlign.CenterX -> (x0 + x1 - b.width) / 2f - b.x @@ -463,6 +469,17 @@ private fun unitOf(hud: Hud, entered: HudGroup?): Set { return out } +/** + * Whether a click on [hud] picks something that cannot move + * + * A locked HUD in a group only holds still inside it, so a group is held only once all of it is locked + */ +private fun unitLocked(hud: Hud, entered: HudGroup?): Boolean = unitOf(hud, entered).all { it.locked } + +/** Every HUD that can be picked and moved at the level the user is working at */ +private fun movableHuds(entered: HudGroup?): Set = + HudManager.activeInstances.filterNot { unitLocked(it, entered) }.flatMapTo(LinkedHashSet()) { unitOf(it, entered) } + /** The groups whose every HUD is in [selected] at the level the user is currently picking at */ private fun selectedGroupsIn(selected: Set, entered: HudGroup?): List { val out = ArrayList() @@ -482,15 +499,6 @@ private fun selectionUnits(selected: Set, entered: HudGroup?): List titles.joinToString(", ") - else -> "${titles[0]}, ${titles[1]} +${titles.size - 2}" - } -} - /** The nine points of a HUD box the user can pin another HUD to in reading order */ private val ANCHOR_POINTS = listOf( HudAnchor.TopLeft, HudAnchor.Top, HudAnchor.TopRight, @@ -616,15 +624,16 @@ private fun hitTestHud(hud: Hud, screenX: Float, screenY: Float): Boolean { * * Locked huds are only considered when nothing unlocked is under the cursor */ -private fun hudStackAt(screenX: Float, screenY: Float): List { +private fun hudStackAt(screenX: Float, screenY: Float, entered: HudGroup?): List { val stack = orderedInstances().filter { hitTestHud(it, screenX, screenY) } - return stack.filter { !it.locked }.ifEmpty { stack } + return stack.filter { !unitLocked(it, entered) }.ifEmpty { stack } } -private fun topHudAt(screenX: Float, screenY: Float): Hud? = hudStackAt(screenX, screenY).lastOrNull() +private fun topHudAt(screenX: Float, screenY: Float, entered: HudGroup? = null): Hud? = + hudStackAt(screenX, screenY, entered).lastOrNull() -private fun pickHudAt(screenX: Float, screenY: Float, current: Hud?): Hud? { - val stack = hudStackAt(screenX, screenY) +private fun pickHudAt(screenX: Float, screenY: Float, current: Hud?, entered: HudGroup?): Hud? { + val stack = hudStackAt(screenX, screenY, entered) if (stack.isEmpty()) return null val index = stack.indexOfFirst { it === current } if (index < 0) return stack.last() @@ -1271,6 +1280,16 @@ fun HudDesignStudio(visible: Boolean = true) { fun soleGroup(): HudGroup? = selectedGroupsIn(selectedHuds, enteredGroup).singleOrNull()?.takeIf { it.huds.size == selectedHuds.size } + /** Steps out of the group the user went into one level, selecting it */ + fun stepOutOfGroup() { + val entered = enteredGroup ?: return + val member = entered.huds.firstOrNull() + Snapshot.withMutableSnapshot { + enteredGroup = entered.parent + if (member != null) selectedHuds = unitOf(member, entered.parent) + } + } + fun groupSelection() { val groups = selectedGroupsIn(selectedHuds, enteredGroup) val loose = selectedHuds.filter { hud -> groups.none { hud in it } } @@ -1293,6 +1312,9 @@ fun HudDesignStudio(visible: Boolean = true) { val panelHud: Hud? = if (panelOpen) primaryHud() else null val panelVisible = panelHud != null + val panelGroup: HudGroup? = if (panelOpen) soleGroup() else null + // several HUDs that are not one group have no panel of their own so the panel shows what they share + val panelHuds: List = if (panelOpen && panelGroup == null && selectedHuds.size > 1) selectedHuds.toList() else emptyList() fun clearTransientEditorState() { hudContextMenuTarget = null @@ -1408,7 +1430,7 @@ fun HudDesignStudio(visible: Boolean = true) { } else if (pending != null) { if (pending in HudManager.activeInstances) { Snapshot.withMutableSnapshot { - selectedHuds = setOf(pending) + selectedHuds = unitOf(pending, null) panelOpen = true } } @@ -1431,14 +1453,16 @@ fun HudDesignStudio(visible: Boolean = true) { DisposableEffect(Unit) { onDispose { HudDesignSession.activeSelection = emptyList() + HudDesignSession.activeGroup = null HudDesignSession.clearCommands() } } - LaunchedEffect(selectedHuds, tourBlocksEditor) { + LaunchedEffect(selectedHuds, enteredGroup, tourBlocksEditor) { val primary = primaryHud() primary?.let { repairHudStaticSize(it) } HudDesignSession.activeSelection = if (tourBlocksEditor) emptyList() else selectedHuds.toList() + HudDesignSession.activeGroup = if (tourBlocksEditor) null else soleGroup() } val providers = remember { HudManager.providers().toList() } @@ -1499,7 +1523,7 @@ fun HudDesignStudio(visible: Boolean = true) { StudioCommand.Delete -> deleteHuds(selectedHuds) StudioCommand.SelectAll -> Snapshot.withMutableSnapshot { - selectedHuds = HudManager.activeInstances.filter { !it.locked }.toSet() + selectedHuds = movableHuds(enteredGroup) } StudioCommand.Lock -> { @@ -1676,7 +1700,7 @@ fun HudDesignStudio(visible: Boolean = true) { val right = maxOf(start.x, cur.x) * s val bottom = maxOf(start.y, cur.y) * s val intersecting = HudManager.activeInstances.filter { hud -> - if (hud.locked) return@filter false + if (unitLocked(hud, enteredGroup)) return@filter false val b = hudBounds(hud) ?: return@filter false b.x < right && b.x + b.width > left && b.y < bottom && b.y + b.height > top }.flatMapTo(LinkedHashSet()) { unitOf(it, enteredGroup) } @@ -1732,7 +1756,7 @@ fun HudDesignStudio(visible: Boolean = true) { } if (event.buttons.isSecondaryPressed) { val hit = primaryHud()?.takeIf { hitTestHud(it, pos.x, pos.y) } - ?: topHudAt(pos.x, pos.y) + ?: topHudAt(pos.x, pos.y, enteredGroup) event.changes.forEach { it.consume() } Snapshot.withMutableSnapshot { pressWasSecondary = true @@ -1762,7 +1786,7 @@ fun HudDesignStudio(visible: Boolean = true) { val groupHandle = groupBounds?.let { hitTestResizeHandle(it, pos.x, pos.y, mcToScreen) } if (groupBounds != null && groupHandle != null) { event.changes.forEach { it.consume() } - if (groupHuds.any { it.locked }) { + if (groupHuds.all { it.locked }) { notifyHudLocked() return@safePointerEvent } @@ -1818,12 +1842,24 @@ fun HudDesignStudio(visible: Boolean = true) { val s = Platform.screen().screenToMcScale() val actionPressed = KeybindUtils.isActionModifierPressed(event.keyboardModifiers) val shift = event.keyboardModifiers.isShiftPressed - val hit = pickHudAt(pos.x, pos.y, primaryHud()) + val hit = pickHudAt(pos.x, pos.y, primaryHud(), enteredGroup) // a click outside the group the user stepped into steps back out of it if (hit == null && !actionPressed && !shift || hit != null && enteredGroup?.let { it !in HudGroups.chainOf(hit) } == true) { enteredGroup = null } - if (hit != null && hit.locked) { + // a second click on a grouped HUD steps into the group and picks what is under the cursor inside it, + // which has to work on locked HUDs too or the only way to reach one to unlock it would be to ungroup + val now = System.currentTimeMillis() + // overlapping HUDs take turns under repeated clicks, so two clicks on one group count even when the HUD differs + val lastHud = lastClickHud + val sameTarget = hit != null && lastHud != null && (lastHud === hit || + unitGroupOf(hit, enteredGroup)?.let { it == unitGroupOf(lastHud, enteredGroup) } == true) + val doubleClick = sameTarget && !actionPressed && !shift && now - lastClickTime[0] <= DOUBLE_CLICK_MS + val stepInto = if (doubleClick) unitGroupOf(hit, enteredGroup) else null + if (stepInto != null) enteredGroup = stepInto + lastClickHud = if (doubleClick) null else hit + lastClickTime[0] = now + if (hit != null && unitLocked(hit, enteredGroup)) { event.changes.forEach { it.consume() } if (hit !in selectedHuds) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { @@ -1838,14 +1874,6 @@ fun HudDesignStudio(visible: Boolean = true) { if (hit != null) event.changes.forEach { it.consume() } Snapshot.withMutableSnapshot { if (hit != null) { - // a second click on a grouped HUD steps into the group and picks what is under the cursor inside it - val now = System.currentTimeMillis() - val doubleClick = !actionPressed && !shift && lastClickHud === hit && - now - lastClickTime[0] <= DOUBLE_CLICK_MS - val stepInto = if (doubleClick) unitGroupOf(hit, enteredGroup) else null - if (stepInto != null) enteredGroup = stepInto - lastClickHud = if (doubleClick) null else hit - lastClickTime[0] = now val unit = unitOf(hit, enteredGroup) val wasSelected = stepInto == null && unit.all { it in selectedHuds } val newSelection = when { @@ -1897,16 +1925,13 @@ fun HudDesignStudio(visible: Boolean = true) { if (event.changes.none { it.pressed }) { pendingPress = null } else { - if (press.hud !in selectedHuds || press.hud.locked) return@safePointerEvent + if (press.hud !in selectedHuds || press.unit.all { it.locked }) return@safePointerEvent if ((pos - press.origin).getDistance() <= DRAG_SLOP_PX) return@safePointerEvent - // a group only moves whole so one locked HUD in it holds the group in place - if (press.unit.any { it.locked }) { - pendingPress = null - notifyHudLocked() - return@safePointerEvent - } UiSounds.play(UiSoundEvent.HUD_DRAG_START) - val movable = selectedHuds.filterTo(LinkedHashSet()) { !it.locked } + // a locked HUD in a group rides along with the group + val movable = selectionUnits(selectedHuds, enteredGroup) + .filterNot { unit -> unit.all { it.locked } } + .flatMapTo(LinkedHashSet()) { it } // grabbing an unselected HUD drags the whole fused shape while grabbing an already selected // one pulls it back out of that shape val detach = press.wasSelected @@ -2019,7 +2044,7 @@ fun HudDesignStudio(visible: Boolean = true) { hoveredHud = null return@safePointerEvent } - val hit = topHudAt(pos.x, pos.y) + val hit = topHudAt(pos.x, pos.y, enteredGroup) val mcToScreen = Platform.screen().mcToScreenScale() val hovered = hoveredHud // over the bar or on the way to it the hover must stick even over another HUD or the @@ -2104,7 +2129,7 @@ fun HudDesignStudio(visible: Boolean = true) { if (hoveredHud?.let { hoverBar(it, mcToScreen)?.second?.contains(pos.x, pos.y) } == true) { return@safePointerEvent } - val hit = pickHudAt(pos.x, pos.y, primaryHud()) + val hit = pickHudAt(pos.x, pos.y, primaryHud(), enteredGroup) if (hit != null) { if (hit !in selectedHuds) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { @@ -2309,13 +2334,9 @@ fun HudDesignStudio(visible: Boolean = true) { } return@onKeyEvent true } - // escape climbs out of a group the user stepped into one level at a time, selecting it - enteredGroup?.takeIf { keyEvent.key == Key.Escape }?.let { entered -> - val member = entered.huds.firstOrNull() - Snapshot.withMutableSnapshot { - enteredGroup = entered.parent - if (member != null) selectedHuds = unitOf(member, entered.parent) - } + // escape climbs out of a group the user stepped into one level at a time + if (keyEvent.key == Key.Escape && enteredGroup != null) { + stepOutOfGroup() return@onKeyEvent true } if (keyEvent.key == Key.Escape && (selectedHuds.isNotEmpty() || panelOpen)) { @@ -2345,7 +2366,7 @@ fun HudDesignStudio(visible: Boolean = true) { Key.DirectionDown -> 0f to step else -> return@onKeyEvent false } - if (selectedHuds.any { it.locked }) { + if (selectionUnits(selectedHuds, enteredGroup).any { unit -> unit.all { it.locked } }) { notifyHudLocked() return@onKeyEvent true } @@ -2484,13 +2505,16 @@ fun HudDesignStudio(visible: Boolean = true) { val members = group.huds val b = boundsOf(members) ?: continue val alone = group == soleGroup - drawSelectedHudBounds( - b, - mcToScreen, - badgeRadius = badgeRadius, - showHandles = alone && members.none { it.locked }, - showBadge = alone, - ) + if (members.all { it.locked }) { + drawRect( + color = lockedHudBoxColor, + topLeft = Offset(b.x * mcToScreen, b.y * mcToScreen), + size = Size(b.width * mcToScreen, b.height * mcToScreen), + style = Stroke(width = 1f, pathEffect = lockedHudDashEffect), + ) + continue + } + drawSelectedHudBounds(b, mcToScreen, showHandles = alone, badgeRadius = badgeRadius, showBadge = alone) } // hovering an anchored HUD traces the link back to the point it hangs off hoveredHud?.let { hovered -> @@ -2625,7 +2649,9 @@ fun HudDesignStudio(visible: Boolean = true) { onDelete = { deleteHuds(unit) }, ) { Snapshot.withMutableSnapshot { - selectedHuds = unit + // part of a larger selection opens the settings of all of it + val inLargerSelection = selectedHuds.size > unit.size && selectedHuds.containsAll(unit) + if (!inLargerSelection) selectedHuds = unit panelOpen = true } } @@ -2639,13 +2665,13 @@ fun HudDesignStudio(visible: Boolean = true) { anchorPickSources.isNotEmpty() -> "Click the point to anchor it to · Right click to remove the anchor · Esc to go back" isDragging -> null - lockedPressHud != null || hoveredHud?.locked == true -> + lockedPressHud != null || hoveredHud?.let { unitLocked(it, enteredGroup) } == true -> "This HUD is locked · Use the lock button above it to move or resize it" - hoveredHud?.let { !it.locked && unitGroupOf(it, enteredGroup) != null } == true -> + hoveredHud?.let { unitGroupOf(it, enteredGroup) != null } == true -> "Drag to move the group · Drag a corner to scale it · Double-click to edit inside it" enteredGroup != null && hoveredHud == null -> "Editing inside a group · Esc to step out" - hoveredHud?.let { it.bgMerged && !it.locked } == true -> + hoveredHud?.let { it.bgMerged && !unitLocked(it, enteredGroup) } == true -> "Drag to move the merged HUDs together · Click it first, then drag, to pull it out" else -> null } @@ -2708,7 +2734,13 @@ fun HudDesignStudio(visible: Boolean = true) { .hoverable(panelInteraction) ) { val panelContentHud = remember { mutableStateOf(null) } - if (panelHud != null) panelContentHud.value = panelHud + val panelContentGroup = remember { mutableStateOf(null) } + val panelContentHuds = remember { mutableStateOf>(emptyList()) } + if (panelHud != null) { + panelContentHud.value = panelHud + panelContentGroup.value = panelGroup + panelContentHuds.value = panelHuds + } val panelSlidePx = (if (panelPos.x < 0.5f) -1 else 1) * with(densityObj) { 24.dp.roundToPx() } AnimatedVisibility( visible = panelHud != null, @@ -2721,6 +2753,15 @@ fun HudDesignStudio(visible: Boolean = true) { DesignStudioPanel( modifier = Modifier.chromeBlocker(CHROME_SETTINGS_PANEL), selectedHud = panelContentHud.value, + selectedGroup = panelContentGroup.value, + sharedHuds = panelContentHuds.value, + onStepOut = if (enteredGroup != null) ({ stepOutOfGroup() }) else null, + onOpenGroupMember = { members -> + Snapshot.withMutableSnapshot { + enteredGroup = panelGroup + selectedHuds = members + } + }, activeCategory = activeCategory, onCategoryChange = { activeCategory = it }, searchFocusRequested = searchFocusTarget == StudioSearch.Panel, @@ -2890,11 +2931,11 @@ fun HudDesignStudio(visible: Boolean = true) { offset = hudContextMenuOffset, onDismiss = { hudContextMenuTarget = null }, targets = contextMenuTargets, - settingsEnabled = menuUnits.size <= 1, onSettings = { hud -> Snapshot.withMutableSnapshot { hudContextMenuTarget = null - selectedHuds = unitOf(hud, enteredGroup) + // several HUDs or groups keep the whole selection so the panel shows what they share + selectedHuds = if (menuUnits.size > 1) menuTargetSet else unitOf(hud, enteredGroup) panelOpen = true } }, @@ -2990,7 +3031,7 @@ fun HudDesignStudio(visible: Boolean = true) { }, onSelectAll = { Snapshot.withMutableSnapshot { - selectedHuds = HudManager.activeInstances.filter { !it.locked }.toSet() + selectedHuds = movableHuds(enteredGroup) } UiSounds.play(UiSoundEvent.CLICK) }, @@ -3250,6 +3291,13 @@ fun HudDragLayer(modifier: Modifier = Modifier) { fun overShell(px: Float, py: Float) = shellLayout.shellBounds?.contains(Offset(px, py)) == true + /** The box the action bar for hovering [hud] hangs off and the bar, which is its whole group's when it is grouped */ + fun hoverBar(hud: Hud, mcToScreen: Float): Pair? { + val unit = unitOf(hud, null) + val bounds = boundsOf(unit) ?: return null + return bounds to hudActionBarLayout(bounds, actionBarIcons(unit, null), mcToScreen, actionIconPx, actionBarGapPx) + } + Box( modifier = modifier .fillMaxSize() @@ -3260,17 +3308,15 @@ fun HudDragLayer(modifier: Modifier = Modifier) { if (overShell(pos.x, pos.y)) return@safePointerEvent if (!isDragging) { val actionTarget = hoveredHud - if (actionTarget != null && hitTestHudActionBar( - actionTarget, pos.x, pos.y, - Platform.screen().mcToScreenScale(), actionIconPx, actionBarGapPx, - ) + if (actionTarget != null && + hoverBar(actionTarget, Platform.screen().mcToScreenScale())?.second?.contains(pos.x, pos.y) == true ) return@safePointerEvent } val s = Platform.screen().screenToMcScale() - val hit = orderedInstances().lastOrNull { !it.locked && hitTestHud(it, pos.x, pos.y) } + val hit = orderedInstances().lastOrNull { !unitLocked(it, null) && hitTestHud(it, pos.x, pos.y) } val unit = hit?.let { unitOf(it, null) } - if (hit == null || unit == null || unit.any { it.locked }) { - (hit ?: orderedInstances().lastOrNull { it.locked && hitTestHud(it, pos.x, pos.y) })?.let { + if (hit == null || unit == null) { + orderedInstances().lastOrNull { hitTestHud(it, pos.x, pos.y) }?.let { Snapshot.withMutableSnapshot { lockedPressHud = it lockedPressOrigin = pos @@ -3327,11 +3373,12 @@ fun HudDragLayer(modifier: Modifier = Modifier) { val hit = if (overShell(pos.x, pos.y)) null else topHudAt(pos.x, pos.y) val hovered = hoveredHud val mcToScreen = Platform.screen().mcToScreenScale() - val overActionBar = hovered?.let { - hitTestHudActionBarZone(it, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) + val hoveredBar = hovered?.let { hoverBar(it, mcToScreen) } + val overActionBar = hoveredBar?.let { (bounds, bar) -> + hitTestHudActionBarZone(bounds, bar, pos.x, pos.y, mcToScreen) } == true - val inExpandedZone = hovered?.let { - hitTestHudWithActionBar(it, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) + val inExpandedZone = hoveredBar?.let { (bounds, bar) -> + hitTestHudWithActionBar(bounds, bar, pos.x, pos.y, mcToScreen) } == true val next = when { overShell(pos.x, pos.y) -> null @@ -3361,9 +3408,9 @@ fun HudDragLayer(modifier: Modifier = Modifier) { val sy = bounds.y * mcToScreen val sw = bounds.width * mcToScreen val sh = bounds.height * mcToScreen - val isBeingDragged = hud === draggedHud && isDragging + val isBeingDragged = isDragging && hud in dragSet val isHovered = hud === hoveredHud - if (hud.locked) { + if (hud.locked && !isBeingDragged) { if (isHovered) { drawRect( color = lockedHudBoxColor, @@ -3411,28 +3458,31 @@ fun HudDragLayer(modifier: Modifier = Modifier) { val actionBarTarget = hoveredHud if (actionBarTarget != null && !isDragging) { val mcToScreen = Platform.screen().mcToScreenScale() - val bounds = hudBounds(actionBarTarget) - val layout = hudActionBarLayout(actionBarTarget, mcToScreen, actionIconPx, actionBarGapPx) + val unit = unitOf(actionBarTarget, null) + val bar = hoverBar(actionBarTarget, mcToScreen) + val bounds = bar?.first + val layout = bar?.second // HudDragLayer is composed outside OneConfigInterface's Theme scope so provide one for the // icons since IconButton reads LocalTheme if (bounds != null && bounds.width > 0f && bounds.height > 0f && layout != null) Theme { HudActionBar( - huds = listOf(actionBarTarget), + huds = unit.toList(), layout = layout, densityFloat = densityFloat, actionIconPx = actionIconPx, actionBarGapPx = actionBarGapPx, chromeAlpha = 1f, onDelete = { - if (actionBarTarget.canDelete()) { + val removed = unit.filter { it.canDelete() } + if (removed.isNotEmpty()) { Snapshot.withMutableSnapshot { hoveredHud = null - if (draggedHud === actionBarTarget) { + if (removed.any { it === draggedHud }) { draggedHud = null isDragging = false shellLayout.markHudDragging(false) } - HudManager.removeHud(actionBarTarget, delete = true) + removed.forEach { HudManager.removeHud(it, delete = true) } } } }, @@ -3449,6 +3499,12 @@ fun HudDragLayer(modifier: Modifier = Modifier) { private fun DesignStudioPanel( modifier: Modifier = Modifier, selectedHud: Hud?, + selectedGroup: HudGroup?, + /** Several HUDs which are not one group, which the panel shows the settings they share for */ + sharedHuds: List, + /** Steps out of the group being edited inside, or `null` when not editing inside one */ + onStepOut: (() -> Unit)?, + onOpenGroupMember: (Set) -> Unit, activeCategory: StudioCategory, onCategoryChange: (StudioCategory) -> Unit, searchFocusRequested: Boolean, @@ -3462,22 +3518,47 @@ private fun DesignStudioPanel( ) { val theme = LocalTheme.current val legacyHasOptions = rememberHasHudOptions(selectedHud?.takeIf { it is LegacyHud }?.tree) - val categories = remember(selectedHud, legacyHasOptions) { + val shared = selectedGroup == null && sharedHuds.size > 1 + val sharing = selectedGroup?.huds ?: sharedHuds.takeIf { shared } + val categories = remember(selectedHud, selectedGroup, sharedHuds, legacyHasOptions) { StudioCategory.entries.filter { - when (it) { + when { + // a group or several HUDs only have the style options all of them share + sharing != null -> it != StudioCategory.Style || SharedOptions.anyShared(SharedTab.Style, sharing) // hide settings tab for legacy HUDs that would leave it empty - StudioCategory.Settings -> selectedHud !is LegacyHud || legacyHasOptions - StudioCategory.Style -> selectedHud !is LegacyHud - StudioCategory.Layout -> true + it == StudioCategory.Settings -> selectedHud !is LegacyHud || legacyHasOptions + it == StudioCategory.Style -> selectedHud !is LegacyHud + else -> true } } } val shownCategory = activeCategory.takeIf { it in categories } ?: categories.first() LaunchedEffect(shownCategory) { onCategoryChange(shownCategory) } val query = searchText.trim() - val title = remember(selectedHud) { selectedHud?.let { localizedLabel(it.title) } } - val subtitle = remember(selectedHud, title) { - selectedHud?.configId?.let(::modNameFor)?.takeIf { it != title } + // what the panel is showing the settings of, so scrolling starts over when it changes + val view: Any? = selectedGroup ?: if (shared) "shared" else selectedHud + // a group's label and member count follow its HUDs, which the group itself cannot tell has changed + val instancesRevision = HudManager.instancesRevision.intValue + val title = remember(selectedHud, selectedGroup, sharedHuds, instancesRevision) { + when { + selectedGroup != null -> hudGroupLabel(selectedGroup) + shared -> "${sharedHuds.size} HUDs" + else -> selectedHud?.let { localizedLabel(it.title) } + } + } + val subtitle = remember(selectedHud, selectedGroup, sharedHuds, title, instancesRevision) { + when { + selectedGroup != null -> { + val count = selectedGroup.huds.size + val ancestors = generateSequence(selectedGroup.parent) { it.parent }.toList().asReversed() + (ancestors.map(::hudGroupLabel) + "Group · $count HUD${if (count == 1) "" else "s"}").joinToString(" › ") + } + shared -> "Shared settings" + else -> selectedHud?.let { hud -> + val modName = hud.configId?.let(::modNameFor)?.takeIf { it != title } + (HudGroups.chainOf(hud).map(::hudGroupLabel) + listOfNotNull(modName)).joinToString(" › ").ifEmpty { null } + } + } } Box( modifier = Modifier @@ -3534,6 +3615,7 @@ private fun DesignStudioPanel( Text(it, color = theme.textColorSecondary, fontSize = 12.sp) } } + if (onStepOut != null) IconButton("left-arrow", modifier = Modifier.size(32.dp), hoverBorder = true) { onStepOut() } IconButton("close", modifier = Modifier.size(32.dp), hoverBorder = true) { onBack() } } var searchOpen by remember { mutableStateOf(false) } @@ -3602,7 +3684,7 @@ private fun DesignStudioPanel( } }, ) { category -> - val panelScrollState = rememberScrollState() + val panelScrollState = key(view) { rememberScrollState() } ScrollbarBox(panelScrollState, StudioPanelGutter) { SectionStack( Modifier.fillMaxWidth() @@ -3613,9 +3695,21 @@ private fun DesignStudioPanel( // like the main config screen, a search spans every category rather than only the open one categories.forEach { if (query.isNotEmpty() || it == category) when (it) { - StudioCategory.Settings -> Settings(selectedHud, query) - StudioCategory.Style -> Style(selectedHud, query) - StudioCategory.Layout -> Layout(selectedHud, query) + StudioCategory.Settings -> when { + selectedGroup != null -> GroupSettings(selectedGroup, query, onOpenGroupMember) + shared -> SharedSettings(sharedHuds, query) + else -> Settings(selectedHud, query) + } + StudioCategory.Style -> when { + selectedGroup != null -> SharedStyle(selectedGroup.huds, query) + shared -> SharedStyle(sharedHuds, query) + else -> Style(selectedHud, query) + } + StudioCategory.Layout -> when { + selectedGroup != null -> SharedLayout(selectedGroup.huds, query, selectedGroup) + shared -> SharedLayout(sharedHuds, query) + else -> Layout(selectedHud, query) + } } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudGroupLabels.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudGroupLabels.kt new file mode 100644 index 000000000..19e173b2d --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudGroupLabels.kt @@ -0,0 +1,25 @@ +package org.polyfrost.oneconfig.internal.ui.hud.screens + +import org.polyfrost.oneconfig.api.hud.v1.HudGroup +import org.polyfrost.oneconfig.internal.ui.components.localizedLabel + +private fun memberTitles(group: HudGroup): List = group.huds.map { localizedLabel(it.title) ?: it.title } + +/** [group]'s name, or its first two HUDs and how many more there are when a mod gave it none */ +internal fun hudGroupLabel(group: HudGroup): String { + group.name?.let { return it } + val titles = memberTitles(group) + return when { + titles.size <= 2 -> titles.joinToString(", ") + else -> "${titles[0]}, ${titles[1]} +${titles.size - 2}" + } +} + +/** Every HUD in [group] by title, as in "Air, Armor, Text Hud ×3" */ +internal fun hudGroupMembers(group: HudGroup): String = joinMemberTitles(memberTitles(group)) + +/** [titles] in the order they first appear with each repeated title listed once and followed by how many there are */ +internal fun joinMemberTitles(titles: List): String = + titles.groupingBy { it }.eachCount().entries.joinToString(", ") { (title, count) -> + if (count > 1) "$title ×$count" else title + } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistory.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistory.kt index 87e6399c6..e15e7fc8e 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistory.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistory.kt @@ -43,7 +43,7 @@ private class Entry(val hudClass: Class, val values: Map private class Layout(val owner: ConfigManager, val huds: Map) -private fun sameValue(a: Any?, b: Any?): Boolean = +internal fun sameValue(a: Any?, b: Any?): Boolean = if (a is Float && b is Float) abs(a - b) < FLOAT_TOLERANCE else a == b private fun sameLayout(a: Layout, b: Layout): Boolean { @@ -73,7 +73,7 @@ private fun forEachOption(tree: Tree, prefix: String = "", action: (String, Prop } } -private fun comparable(prop: Property<*>): Any? { +internal fun comparable(prop: Property<*>): Any? { val value = prop.get() if (value == null || WrappingUtils.isSimpleClass(prop.type)) return value return try { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt index 6f992fcaa..219bb2ab6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt @@ -137,11 +137,14 @@ internal fun GridRow( } } -/** [label] above its control */ +/** [label] above its control, with a note beside it while the control is [mixed] between several HUDs */ @Composable -internal fun LabeledControl(label: String, content: @Composable () -> Unit) { +internal fun LabeledControl(label: String, mixed: Boolean = false, content: @Composable () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(label, color = LocalTheme.current.textColor, fontSize = 14.sp) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, color = LocalTheme.current.textColor, fontSize = 14.sp) + if (mixed) Text("Mixed", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + } content() } } @@ -160,8 +163,8 @@ internal fun ControlRow(label: String, content: @Composable RowScope.() -> Unit) } @Composable -internal fun SwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - ControlRow(label) { SwitchControl(checked, onCheckedChange) } +internal fun SwitchRow(label: String, checked: Boolean, mixed: Boolean = false, onCheckedChange: (Boolean) -> Unit) { + ControlRow(label) { SwitchControl(checked, mixed, onCheckedChange) } } /** diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/GroupSettings.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/GroupSettings.kt new file mode 100644 index 000000000..3db170b4b --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/GroupSettings.kt @@ -0,0 +1,70 @@ +package org.polyfrost.oneconfig.internal.ui.hud.screens.sections + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.hud.v1.HudGroup +import org.polyfrost.oneconfig.api.hud.v1.HudManager +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.localizedLabel +import org.polyfrost.oneconfig.internal.ui.components.onClick +import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.hud.screens.hudGroupMembers +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +/** The Settings tab of [group], which lists what is inside it and has what its HUDs share, with [onOpen] stepping into one */ +@Composable +fun GroupSettings(group: HudGroup, query: String, onOpen: (Set) -> Unit) { + // the group reads the HUD list, which compose cannot see change + HudManager.instancesRevision.intValue + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + CardSection("HUDs", query) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + for (sub in group.subGroups) { + GroupRow(sub.name ?: hudGroupMembers(sub), "group") { onOpen(sub.huds.toSet()) } + } + for (hud in group.directHuds) { + GroupRow(localizedLabel(hud.title) ?: hud.title, "right-arrow") { onOpen(setOf(hud)) } + } + } + } + SharedSettings(group.huds, query) + } +} + +@Composable +internal fun GroupRow(text: String, icon: String, onClick: () -> Unit) { + val theme = LocalTheme.current + val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() + val background by animateColorAsState(theme.textColor.copy(alpha = if (isHovered) 0.10f else 0f)) + Row( + modifier = Modifier + .fillMaxWidth() + .pointerHoverIcon(PointerIcon.Hand) + .background(background, theme.buttonShape) + .onClick(interactionSource, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, color = theme.textColorSecondary, modifier = Modifier.size(14.dp)) + Text(text, color = theme.textColor, fontSize = 13.sp, modifier = Modifier.weight(1f)) + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt index f859441e7..943e0a72b 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt @@ -253,7 +253,7 @@ fun Layout(hud: Hud?, query: String) { } @Composable -private fun PadSpinner(label: String, value: Float, width: Dp, onChange: (Float) -> Unit) { +internal fun PadSpinner(label: String, value: Float, width: Dp, onChange: (Float) -> Unit) { LabeledControl(label) { NumberSpinner(value, { Snapshot.withMutableSnapshot { onChange(it) } }, 0f, 100f, 1f, width = width, unit = "px") } @@ -261,7 +261,7 @@ private fun PadSpinner(label: String, value: Float, width: Dp, onChange: (Float) /** X and Y of the selection's top left corner, moving every selected HUD together, with [third] in the last column */ @Composable -private fun HudPositionFields(hud: Hud, col1: Dp, third: @Composable () -> Unit) { +internal fun HudPositionFields(hud: Hud, col1: Dp, third: @Composable () -> Unit) { HudEditorViewport.observe() val selection = designerSelection(hud) val origin = selectionOrigin(selection) ?: Offset(hud.x, hud.y) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettings.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettings.kt new file mode 100644 index 000000000..84c11b216 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettings.kt @@ -0,0 +1,424 @@ +package org.polyfrost.oneconfig.internal.ui.hud.screens.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.function.Consumer +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer +import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.hud.v1.HudGroup +import org.polyfrost.oneconfig.api.hud.v1.HudGroups +import org.polyfrost.oneconfig.api.hud.v1.HudResize +import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.blockInteraction +import org.polyfrost.oneconfig.internal.ui.components.settings.Dropdown +import org.polyfrost.oneconfig.internal.ui.components.settings.NumberSpinner +import org.polyfrost.oneconfig.internal.ui.components.settings.Radio +import org.polyfrost.oneconfig.internal.ui.components.settings.SwitchControl +import org.polyfrost.oneconfig.internal.ui.hud.HudSettingsContent +import org.polyfrost.oneconfig.internal.ui.hud.repairHudStaticSize +import org.polyfrost.oneconfig.internal.ui.screens.ConfigListRow +import org.polyfrost.oneconfig.internal.ui.screens.HudCategoryList +import org.polyfrost.oneconfig.internal.ui.screens.HudOptionRow +import org.polyfrost.oneconfig.internal.ui.search.CategoryGroup +import org.polyfrost.oneconfig.internal.ui.search.ConfigListEntry +import org.polyfrost.oneconfig.internal.ui.search.SettingNode +import org.polyfrost.oneconfig.internal.ui.search.SubcategoryGroup +import org.polyfrost.oneconfig.internal.ui.search.nodeGroup +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +/** Counts edits so rows read the HUDs again after one, since plain HUD fields cannot tell compose they changed */ +private class SharedEdits { + private var count by mutableIntStateOf(0) + + val revision: Int get() = count + + fun bump() { + count++ + } +} + +private fun SharedValue.isOn(): Boolean = (this as? SharedValue.Single)?.value == true + +private fun SharedValue<*>.isMixed(): Boolean = this is SharedValue.Mixed + +/** + * What an edit of the shared tabs is written to + * + * A setting the HUDs hold differently reads as mixed until it is changed, which sets it for every HUD + */ +@Composable +private fun SharedContent(huds: Collection, content: @Composable (List, SharedEdits) -> Unit) { + val list = huds.toList() + if (list.isEmpty()) return + + LaunchedEffect(list) { list.forEach(::repairHudStaticSize) } + + HudSettingsContent(list) { + val edits = remember { SharedEdits() } + content(list, edits) + } +} + +/** The Layout tab for [huds], where a [group] has the lock and scale of its own */ +@Composable +fun SharedLayout(huds: Collection, query: String, group: HudGroup? = null) { + SharedContent(huds) { list, _ -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PositionAndScale(list, query, group) + SharedPadding(list, query) + } + } +} + +/** The Style tab for [huds] */ +@Composable +fun SharedStyle(huds: Collection, query: String) { + SharedContent(huds) { list, edits -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SharedCard(SharedSection.Text, list, query, edits) + SharedCard(SharedSection.Background, list, query, edits) + } + } +} + +/** The Settings tab for [huds], which is when they show and the options mods defined that all of them have */ +@Composable +fun SharedSettings(huds: Collection, query: String) { + SharedContent(huds) { list, edits -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SharedPropertyRows(list, query, edits) + SharedCard(SharedSection.Visibility, list, query, edits) + } + } +} + +@Composable +private fun PositionAndScale(huds: List, query: String, group: HudGroup?) { + val scalable = huds.any { it.resizeAxes == HudResize.Both } + val locked = group?.locked ?: resolveShared(huds) { it.locked }.isOn() + val mixedLock = group == null && resolveShared(huds) { it.locked }.isMixed() + CardSection( + if (scalable) "Position & Scale" else "Position", query, + "Lock position", *if (scalable) arrayOf("Scale", "Match scale") else emptyArray(), + ) { col1 -> + HudPositionFields(huds.first(), col1) { + if (scalable) { + LabeledControl("Scale") { + NumberSpinner( + HudGroups.scaleOf(huds), { HudGroups.setScale(huds, it) }, + HudGroups.scaleRange(huds).start, HudGroups.scaleRange(huds).endInclusive, 0.25f, + width = GridCol3, unit = "x", enabled = !locked, + ) + } + } + } + SwitchRow("Lock position", locked, mixedLock) { + if (group != null) HudGroups.setLocked(group, it) else HudGroups.setLocked(huds, it) + } + if (!locked && huds.any { it.locked } && group != null) { + Text( + "Some HUDs are locked on their own and hold still inside the group.", + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + ) + } + if (scalable) { + val scale = HudGroups.scaleOf(huds) + if (!HudGroups.hasUniformScale(huds)) { + GroupRow("Set every HUD to %.2fx".format(scale), "align") { HudGroups.matchScale(huds, scale) } + } + } + } +} + +/** The options of [section] that every HUD has, laid out in the panel grid with switches and segmented choices on rows of their own */ +@Composable +private fun SharedCard(section: SharedSection, huds: List, query: String, edits: SharedEdits) { + val options = SharedOptions.shared(section, huds) + if (options.isEmpty()) return + // the background card's own switch turns the whole card on or off + val master = options.firstOrNull { it.id == "showBackground" } as? SharedToggle + // read so the card reads the HUDs again after an edit + edits.revision + val masterValue = master?.resolve(huds) + CardSection( + section.title, query, *options.map { it.label }.toTypedArray(), + checked = masterValue?.isOn(), + onCheckedChange = { master?.apply(huds, it); edits.bump() }, + ) { col1 -> + val widths = listOf(col1, GridCol2, GridCol3) + val cells = ArrayList() + + @Composable + fun flush() { + for (row in cells.chunked(3)) { + GridRow( + col1, + first = { SharedCell(row[0], huds, edits, widths[0]) }, + second = { row.getOrNull(1)?.let { SharedCell(it, huds, edits, widths[1]) } }, + third = { row.getOrNull(2)?.let { SharedCell(it, huds, edits, widths[2]) } }, + ) + } + cells.clear() + } + + for (option in options) { + if (option === master || !SharedOptions.isShown(option, huds)) continue + when { + option is SharedToggle -> { + flush() + key(option.id) { + val value = option.resolve(huds) + SwitchRow(option.label, value.isOn(), value.isMixed()) { + option.apply(huds, it) + edits.bump() + } + } + } + option is SharedChoice<*> && option.segmented -> { + flush() + GridRow(col1, wide = { SharedCell(option, huds, edits, col1 + GridGap + GridCol2) }) + } + else -> cells += option + } + } + flush() + } +} + +/** The padding of every HUD, which edits both sides of an axis together until it is split per side */ +@Composable +private fun SharedPadding(huds: List, query: String) { + val sides = listOf("padLeft", "padRight", "padTop", "padBottom").map { SharedOptions.find(it) as? SharedAmount ?: return } + if (!sides.all { SharedOptions.isShared(it, huds) }) return + val (left, right, top, bottom) = sides + // read so the fields read the HUDs again after an edit + var revision by remember { mutableIntStateOf(0) } + revision + val edit = { write: (Hud, Float) -> Unit, value: Float -> setShared(huds, value, write); revision++ } + // opens with a field per side when the sides already differ + var perSide by remember { mutableStateOf(huds.any { it.padLeft != it.padRight || it.padTop != it.padBottom }) } + + @Composable + fun Pad(label: String, option: SharedAmount, width: Dp, write: (Hud, Float) -> Unit) { + val value = option.resolve(huds) + LabeledControl(label, value.isMixed()) { + NumberSpinner( + (value as? SharedValue.Single)?.value ?: option.read(huds.first()), + { edit(write, it) }, 0f, 100f, 1f, width = width, unit = "px", mixed = value.isMixed(), + ) + } + } + + CardSection("Padding", query, "Horizontal", "Vertical", "Per side", "Left", "Right", "Top", "Bottom") { col1 -> + RowWithFold( + perSide, + row = { + GridRow( + col1, + first = { + if (perSide) Pad("Left", left, col1) { h, v -> h.padLeft = v } + else Pad("Horizontal", left, col1) { h, v -> h.padLeft = v; h.padRight = v } + }, + second = { + if (perSide) Pad("Top", top, GridCol2) { h, v -> h.padTop = v } + else Pad("Vertical", top, GridCol2) { h, v -> h.padTop = v; h.padBottom = v } + }, + third = { + LabeledControl("Per side") { + SwitchControl(perSide, { + perSide = it + // leaving per side mode evens the sides out so the two fields show what is applied + if (!it) { + Snapshot.withMutableSnapshot { huds.forEach { h -> h.padRight = h.padLeft; h.padBottom = h.padTop } } + revision++ + } + }) + } + }, + ) + }, + folded = { + GridRow( + col1, + first = { Pad("Right", right, col1) { h, v -> h.padRight = v } }, + second = { Pad("Bottom", bottom, GridCol2) { h, v -> h.padBottom = v } }, + ) + }, + ) + } +} + +@Composable +private fun SharedCell(option: SharedOption, huds: List, edits: SharedEdits, width: Dp) { + // read so the control reads the HUDs again after an edit + edits.revision + key(option.id) { + when (option) { + is SharedToggle -> Unit + + is SharedAmount -> { + val value = option.resolve(huds) + LabeledControl(option.label, value.isMixed()) { + NumberSpinner( + (value as? SharedValue.Single)?.value ?: option.read(huds.first()), + { + option.apply(huds, it) + edits.bump() + }, + option.min, option.max, option.step, + width = width, unit = option.unit, mixed = value.isMixed(), + ) + } + } + + is SharedColor -> { + val first = huds.first() + val color = (option.resolve(huds) as? SharedValue.Single)?.value ?: option.read(first) + val chroma = (resolveShared(huds, option.readChroma) as? SharedValue.Single)?.value ?: option.readChroma(first) + val speed = (resolveShared(huds, option.readSpeed) as? SharedValue.Single)?.value ?: option.readSpeed(first) + LabeledControl(option.label, option.isMixed(huds)) { + ColorSwatch( + Color(color), + chroma = chroma, chromaSpeed = speed, + onColorChanged = { + option.apply(huds, it.toArgb()) + edits.bump() + }, + onChromaChanged = { enabled, chromaSpeed -> + option.applyChroma(huds, enabled, chromaSpeed) + edits.bump() + }, + ) + } + } + + is SharedModifiers -> LabeledControl(option.label) { + ModifierButtons( + option.flags.map { flag -> + val value = flag.resolve(huds) + // a mix of both turns on first + ModifierToggle(flag.icon, value.isOn()) { + flag.apply(huds, !value.isOn()) + edits.bump() + } + }, + ) + } + + is SharedChoice<*> -> SharedChoiceControl(option, huds, edits, width) + } + } +} + +@Composable +private fun SharedChoiceControl(option: SharedChoice, huds: List, edits: SharedEdits, width: Dp) { + val value = option.resolve(huds) + val mixed = value.isMixed() + val shown = (value as? SharedValue.Single)?.value ?: option.read(huds.first()) + val selected = option.values.indexOf(shown) + val onSelect = { index: Int -> + option.apply(huds, option.values[index]) + edits.bump() + } + val icon = option.icon + LabeledControl(option.label, mixed) { + if (option.segmented) { + Radio(option.values.map(option.name), if (mixed) -1 else selected, fillWidth = true, onSelect = onSelect) + } else if (icon != null) { + Radio( + option.values.map { null }, if (mixed) -1 else selected, + fillWidth = true, icons = option.values.map(icon), onSelect = onSelect, + ) + } else { + Dropdown(option.values.map(option.name), selected, onSelect, width, mixed) + } + } +} + +/** Counts display changes of the options mods defined, since which of them every HUD shows depends on them */ +@Composable +private fun rememberDisplayRevision(huds: List): Int { + var revision by remember(huds) { mutableIntStateOf(0) } + DisposableEffect(huds) { + val listener = Consumer { revision++ } + val props = huds.flatMap(SharedProperties::candidates) + props.forEach { it.addDisplayListener(listener) } + onDispose { props.forEach { it.removeDisplayListener(listener) } } + } + return revision +} + +/** The options mods defined, which are grouped into the categories the mod gave them like the HUD editor does for a single HUD */ +@Composable +private fun SharedPropertyRows(huds: List, query: String, edits: SharedEdits) { + val revision = rememberDisplayRevision(huds) + val shared = remember(huds, revision) { SharedProperties.find(huds) } + if (shared.isEmpty()) return + val proxies = remember(shared) { shared.associateWith { it.proxy(onWrite = edits::bump) } } + DisposableEffect(proxies) { + val listeners = proxies.map { (option, proxy) -> + Consumer { proxy.revaluateDisplay() }.also { listener -> + option.props.forEach { it.addDisplayListener(listener) } + } + } + onDispose { + proxies.keys.forEachIndexed { index, option -> option.props.forEach { it.removeDisplayListener(listeners[index]) } } + } + } + val bySharedProp = remember(proxies) { proxies.entries.associate { (option, proxy) -> proxy to (option to proxy) } } + val categories = remember(proxies) { + val grouped = LinkedHashMap>>() + for (proxy in proxies.values) { + val category = nodeGroup(proxy, "category", ConfigVisualizer.DEFAULT_CATEGORY) + val subcategory = nodeGroup(proxy, "subcategory", ConfigVisualizer.DEFAULT_SUBCATEGORY) + grouped.getOrPut(category) { LinkedHashMap() }.getOrPut(subcategory) { ArrayList() } += SettingNode.Leaf(proxy) + } + grouped.map { (category, subcategories) -> + CategoryGroup(category, subcategories.map { (name, nodes) -> SubcategoryGroup(name, nodes) }) + } + } + HudCategoryList(categories, query) { entry -> + val prop = ((entry as? ConfigListEntry.Item)?.node as? SettingNode.Leaf)?.prop + val (option, proxy) = bySharedProp[prop] ?: (null to null) + if (option != null && proxy != null) { + key(option.id) { SharedPropertyRow(option, proxy, edits) } + } else { + ConfigListRow(entry) + } + } +} + +@Composable +private fun SharedPropertyRow(shared: SharedProperty, proxy: Property, edits: SharedEdits) { + // read so the note reads the HUDs again after an edit + edits.revision + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HudOptionRow(proxy) + if (shared.resolve().isMixed()) { + Text( + "Mixed between these HUDs, changing it sets it for all of them.", + color = LocalTheme.current.textColorSecondary, + fontSize = 12.sp, + ) + GroupRow("Set every HUD to the value shown", "align") { proxy.setReferential(proxy.get()) } + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettingsModel.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettingsModel.kt new file mode 100644 index 000000000..bf59e2a00 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/SharedSettingsModel.kt @@ -0,0 +1,427 @@ +package org.polyfrost.oneconfig.internal.ui.hud.screens.sections + +import androidx.compose.runtime.snapshots.Snapshot +import java.util.Arrays +import java.util.function.Supplier +import org.polyfrost.oneconfig.api.config.v1.Config +import org.polyfrost.oneconfig.api.config.v1.Properties +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.Visualizer +import org.polyfrost.oneconfig.api.hud.v1.Font +import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.hud.v1.LegacyHudMarker +import org.polyfrost.oneconfig.api.hud.v1.Weight +import org.polyfrost.oneconfig.internal.ui.hud.screens.comparable +import org.polyfrost.oneconfig.internal.ui.hud.screens.sameValue + +/** What the HUDs of a selection hold for one option */ +internal sealed interface SharedValue { + /** Every HUD holds [value] */ + data class Single(val value: T) : SharedValue + + /** The HUDs hold different values */ + data object Mixed : SharedValue +} + +/** The value every HUD of [huds] holds for what [read] reads, or [SharedValue.Mixed] when they differ */ +internal fun resolveShared(huds: Collection, read: (Hud) -> T): SharedValue { + val values = huds.map(read) + val first = values.firstOrNull() ?: return SharedValue.Mixed + return if (values.all { sameValue(first, it) }) SharedValue.Single(first) else SharedValue.Mixed +} + +/** Writes [value] to every HUD of [huds] in one snapshot, so it is a single step in the editor's history */ +internal fun setShared(huds: Collection, value: T, write: (Hud, T) -> Unit) { + Snapshot.withMutableSnapshot { for (hud in huds) write(hud, value) } +} + +/** The editor tab a [SharedSection] is drawn in */ +internal enum class SharedTab { Layout, Style, Settings } + +/** The cards of the shared settings view that [SharedOption]s are drawn in */ +internal enum class SharedSection(val title: String, val tab: SharedTab) { + Padding("Padding", SharedTab.Layout), + Text("Text", SharedTab.Style), + Background("Background", SharedTab.Style), + Visibility("Visibility", SharedTab.Settings), +} + +/** + * A built-in option the HUDs of a selection can share + * + * Only options which the single HUD panels draw by hand are listed, anything else a HUD has is either + * specific to that HUD, like its position, or a mod's own option and is found by [SharedProperties] + */ +internal sealed class SharedOption( + val id: String, + val label: String, + val section: SharedSection, + /** Whether a HUD has the option at all, which it only does for HUDs the single HUD panel draws it for */ + val appliesTo: (Hud) -> Boolean, + /** Whether the option does anything for a HUD, which an option depending on a switch does not while it is off */ + val shownWhen: (Hud) -> Boolean, +) + +internal class SharedToggle( + id: String, + label: String, + section: SharedSection, + val read: (Hud) -> Boolean, + val write: (Hud, Boolean) -> Unit, + appliesTo: (Hud) -> Boolean = ::drawsAppearance, + shownWhen: (Hud) -> Boolean = { true }, +) : SharedOption(id, label, section, appliesTo, shownWhen) { + fun resolve(huds: Collection): SharedValue = resolveShared(huds, read) + + fun apply(huds: Collection, value: Boolean) = setShared(huds, value, write) +} + +internal class SharedAmount( + id: String, + label: String, + section: SharedSection, + val min: Float, + val max: Float, + val step: Float, + val read: (Hud) -> Float, + val write: (Hud, Float) -> Unit, + val unit: String? = "px", + appliesTo: (Hud) -> Boolean = ::drawsAppearance, + shownWhen: (Hud) -> Boolean = { true }, +) : SharedOption(id, label, section, appliesTo, shownWhen) { + fun resolve(huds: Collection): SharedValue = resolveShared(huds, read) + + fun apply(huds: Collection, value: Float) = setShared(huds, value, write) +} + +/** A flag of [SharedModifiers] drawn as an icon */ +internal class SharedFlag( + val label: String, + val icon: String, + val read: (Hud) -> Boolean, + val write: (Hud, Boolean) -> Unit, +) { + fun resolve(huds: Collection): SharedValue = resolveShared(huds, read) + + fun apply(huds: Collection, value: Boolean) = setShared(huds, value, write) +} + +/** Switches drawn as a row of icons that are lit while on, like the text modifiers of a single HUD */ +internal class SharedModifiers( + id: String, + label: String, + section: SharedSection, + val flags: List, + appliesTo: (Hud) -> Boolean = ::drawsAppearance, + shownWhen: (Hud) -> Boolean = { true }, +) : SharedOption(id, label, section, appliesTo, shownWhen) + +/** A color along with whether it cycles through the rainbow and how fast, which are edited together */ +internal class SharedColor( + id: String, + label: String, + section: SharedSection, + val read: (Hud) -> Int, + val write: (Hud, Int) -> Unit, + val readChroma: (Hud) -> Boolean, + val writeChroma: (Hud, Boolean) -> Unit, + val readSpeed: (Hud) -> Float, + val writeSpeed: (Hud, Float) -> Unit, + appliesTo: (Hud) -> Boolean = ::drawsAppearance, + shownWhen: (Hud) -> Boolean = { true }, +) : SharedOption(id, label, section, appliesTo, shownWhen) { + fun resolve(huds: Collection): SharedValue = resolveShared(huds, read) + + /** Mixed as soon as any of the color, chroma or its speed differs between the HUDs */ + fun isMixed(huds: Collection): Boolean = + resolve(huds) is SharedValue.Mixed || + resolveShared(huds, readChroma) is SharedValue.Mixed || + resolveShared(huds, readSpeed) is SharedValue.Mixed + + fun apply(huds: Collection, argb: Int) = setShared(huds, argb, write) + + fun applyChroma(huds: Collection, enabled: Boolean, speed: Float) { + Snapshot.withMutableSnapshot { + for (hud in huds) { + writeChroma(hud, enabled) + writeSpeed(hud, speed) + } + } + } +} + +/** One of [values], drawn as a dropdown or when [icon] is given as a row of icons */ +internal class SharedChoice( + id: String, + label: String, + section: SharedSection, + val values: List, + val name: (E) -> String, + val icon: ((E) -> String)?, + val read: (Hud) -> E, + val write: (Hud, E) -> Unit, + /** Drawn as a row of named choices across the card rather than a dropdown */ + val segmented: Boolean = false, + appliesTo: (Hud) -> Boolean = ::drawsAppearance, + shownWhen: (Hud) -> Boolean = { true }, +) : SharedOption(id, label, section, appliesTo, shownWhen) { + fun resolve(huds: Collection): SharedValue = resolveShared(huds, read) + + fun apply(huds: Collection, value: E) = setShared(huds, value, write) +} + +/** Legacy HUDs draw their own content so the single HUD panel leaves out everything about how it looks */ +internal fun drawsAppearance(hud: Hud): Boolean = hud !is LegacyHudMarker + +private fun mergesBackground(hud: Hud): Boolean = hud !is LegacyHudMarker && hud.canMergeBackground() + +private inline fun > enumChoice( + id: String, + label: String, + section: SharedSection, + noinline icon: ((E) -> String)? = null, + noinline read: (Hud) -> E, + noinline write: (Hud, E) -> Unit, +): SharedChoice = SharedChoice(id, label, section, enumValues().toList(), { it.name }, icon, read, write) + +internal object SharedOptions { + /** The options in the order they are drawn */ + @JvmField + val all: List = listOf( + SharedAmount("padLeft", "Left", SharedSection.Padding, 0f, 100f, 1f, { it.padLeft }, { h, v -> h.padLeft = v }), + SharedAmount("padRight", "Right", SharedSection.Padding, 0f, 100f, 1f, { it.padRight }, { h, v -> h.padRight = v }), + SharedAmount("padTop", "Top", SharedSection.Padding, 0f, 100f, 1f, { it.padTop }, { h, v -> h.padTop = v }), + SharedAmount("padBottom", "Bottom", SharedSection.Padding, 0f, 100f, 1f, { it.padBottom }, { h, v -> h.padBottom = v }), + enumChoice("font", "Font", SharedSection.Text, read = { it.font }, write = { h, v -> h.font = v }), + SharedAmount( + "textScale", "Size", SharedSection.Text, 25f, 500f, 5f, + { it.textScale * 100f }, { h, v -> h.textScale = v / 100f }, unit = "%", + ), + SharedModifiers( + "modifiers", "Modifiers", SharedSection.Text, + listOf( + SharedFlag("Bold", "bold", { it.textBold }, { h, v -> h.textBold = v }), + SharedFlag("Italic", "italic", { it.textItalic }, { h, v -> h.textItalic = v }), + SharedFlag("Underline", "underline", { it.textUnderline }, { h, v -> h.textUnderline = v }), + ), + ), + enumChoice("textWeight", "Weight", SharedSection.Text, read = { it.textWeight }, write = { h, v -> h.textWeight = v }), + enumChoice( + "caseType", "Case", SharedSection.Text, { it.icon }, + read = { CaseType.entries[it.caseType.coerceIn(0, CaseType.entries.lastIndex)] }, + write = { h, v -> h.caseType = v.ordinal }, + ), + SharedColor( + "textColor", "Text color", SharedSection.Text, + { it.textColor }, { h, v -> h.textColor = v }, + { it.textChroma }, { h, v -> h.textChroma = v }, + { it.textChromaSpeed }, { h, v -> h.textChromaSpeed = v }, + ), + SharedChoice( + "shadow", "Shadow", SharedSection.Text, ShadowMode.entries, { it.title }, null, + { if (!it.showShadow) ShadowMode.Off else if (it.fullShadow) ShadowMode.Full else ShadowMode.Normal }, + { h, v -> + h.showShadow = v != ShadowMode.Off + if (h.showShadow) h.fullShadow = v == ShadowMode.Full + }, + segmented = true, + ), + SharedColor( + "shadowColor", "Shadow color", SharedSection.Text, + { it.shadowColor }, { h, v -> h.shadowColor = v }, + { it.shadowChroma }, { h, v -> h.shadowChroma = v }, + { it.shadowChromaSpeed }, { h, v -> h.shadowChromaSpeed = v }, + shownWhen = { it.showShadow }, + ), + SharedToggle("showBackground", "Show background", SharedSection.Background, { it.showBackground }, { h, v -> h.showBackground = v }), + SharedAmount( + "bgRadius", "Radius", SharedSection.Background, 0f, 32f, 1f, + { it.bgRadius }, { h, v -> h.bgRadius = v }, + shownWhen = { it.showBackground }, + ), + SharedColor( + "bgColor", "Color", SharedSection.Background, + { it.bgColor }, { h, v -> h.bgColor = v }, + { it.bgChroma }, { h, v -> h.bgChroma = v }, + { it.bgChromaSpeed }, { h, v -> h.bgChromaSpeed = v }, + shownWhen = { it.showBackground }, + ), + // HUDs drawing their own background opt out of merging so this has nothing to control + SharedChoice( + "merge", "Merge with neighbours", SharedSection.Background, MergeMode.entries, { it.title }, null, + { if (!it.mergeBackground) MergeMode.Off else if (it.mergeDiagonally) MergeMode.Diagonal else MergeMode.Normal }, + { h, v -> + h.mergeBackground = v != MergeMode.Off + if (h.mergeBackground) h.mergeDiagonally = v == MergeMode.Diagonal + }, + appliesTo = ::mergesBackground, shownWhen = { it.showBackground }, + segmented = true, + ), + SharedToggle( + "keepBgWhenHidden", "Keep hidden", SharedSection.Background, + { it.keepBgWhenHidden }, { h, v -> h.keepBgWhenHidden = v }, + appliesTo = ::mergesBackground, shownWhen = { it.showBackground && it.mergeBackground }, + ), + SharedToggle("showInF3", "Show in F3 Screen", SharedSection.Visibility, { it.showInF3 }, { h, v -> h.showInF3 = v }), + SharedToggle("showInTab", "Show in Tab List", SharedSection.Visibility, { it.showInTab }, { h, v -> h.showInTab = v }), + SharedToggle("showInScreens", "Show in GUIs", SharedSection.Visibility, { it.showInScreens }, { h, v -> h.showInScreens = v }), + SharedToggle("showInChat", "Show in Chat", SharedSection.Visibility, { it.showInChat }, { h, v -> h.showInChat = v }), + ) + + @JvmStatic + fun find(id: String): SharedOption? = all.firstOrNull { it.id == id } + + /** Whether every HUD of [huds] has [option] */ + @JvmStatic + fun isShared(option: SharedOption, huds: Collection): Boolean = huds.isNotEmpty() && huds.all(option.appliesTo) + + /** Whether [huds] share any option drawn in [tab] */ + @JvmStatic + fun anyShared(tab: SharedTab, huds: Collection): Boolean = + all.any { it.section.tab == tab && isShared(it, huds) } + + /** Whether [option] does anything for [huds], which is not so while every HUD has what it depends on switched off */ + @JvmStatic + fun isShown(option: SharedOption, huds: Collection): Boolean = huds.any(option.shownWhen) + + /** The options of [section] that every HUD of [huds] has */ + @JvmStatic + fun shared(section: SharedSection, huds: Collection): List = + all.filter { it.section == section && isShared(it, huds) } +} + +/** + * A mod's own option that every HUD of a selection has in the same form, with each HUD's property for it + * + * The properties are in the order of the HUDs + */ +internal class SharedProperty(val id: String, val props: List>) { + /** The property standing in for all of them, which shows the first HUD's value and writes to every HUD */ + fun proxy(onWrite: () -> Unit = {}): Property = SharedProperties.proxy(this, onWrite) + + fun resolve(): SharedValue { + val values = props.map(::comparable) + val first = values.first() + return if (values.all { sameValue(first, it) }) SharedValue.Single(first) else SharedValue.Mixed + } +} + +internal object SharedProperties { + /** Identity text of a HUD, which sharing between HUDs would overwrite */ + private val IDENTITY_OPTIONS = setOf("prefix", "suffix") + + /** Options taking no single value, which are a command, a note or a list rather than a setting */ + private val UNSHARED_VISUALIZERS: Set> = setOf( + Visualizer.ButtonVisualizer::class.java, + Visualizer.InfoVisualizer::class.java, + Visualizer.KeybindVisualizer::class.java, + Visualizer.DraggableListVisualizer::class.java, + Visualizer.MultiSelectDropdownVisualizer::class.java, + Visualizer.NumberChainVisualizer::class.java, + Visualizer.TextListVisualizer::class.java, + Visualizer.ItemListVisualizer::class.java, + Visualizer.EffectListVisualizer::class.java, + Visualizer.FileListVisualizer::class.java, + Visualizer.ColorListVisualizer::class.java, + Visualizer.NumberListVisualizer::class.java, + Visualizer.SliderListVisualizer::class.java, + Visualizer.RangeSliderListVisualizer::class.java, + Visualizer.RangeNumberListVisualizer::class.java, + ) + + /** What has to be the same for two options to be one setting, since each is drawn from it */ + private val MATCHED_METADATA = listOf("options", "optionLabels", "optionValues", "min", "max", "step") + + /** Everything the built-in options already cover, in case a HUD does not hide its own copy */ + private val BUILT_IN_OPTIONS: Set by lazy { + SharedOptions.all.mapTo(HashSet()) { it.id } + listOf("textBold", "textItalic", "textUnderline", "showShadow", "fullShadow", "mergeBackground", "mergeDiagonally") + } + + private fun visualizerClass(prop: Property<*>): Class<*>? = when (val raw = prop.getMetadata("visualizer")) { + null -> null + is Class<*> -> raw + else -> raw.javaClass + } + + /** Whether [prop] is a mod's option which can be shared with the same option of another HUD */ + @JvmStatic + fun isShareable(prop: Property<*>): Boolean { + val id = prop.id ?: return false + if (id in IDENTITY_OPTIONS || id in BUILT_IN_OPTIONS) return false + if (prop.type == Void::class.java || prop.type == Void.TYPE) return false + if (prop.getMetadata("hidden") != null) return false + val visualizer = visualizerClass(prop) ?: return false + return visualizer !in UNSHARED_VISUALIZERS + } + + /** Whether [a] and [b] are the same option, so one control can edit both */ + @JvmStatic + fun matches(a: Property<*>, b: Property<*>): Boolean = + a.id == b.id && a.type == b.type && visualizerClass(a) == visualizerClass(b) && + MATCHED_METADATA.all { key -> + Arrays.deepEquals(arrayOf(a.getMetadata(key)), arrayOf(b.getMetadata(key))) + } + + /** The top level options of [hud] which mods defined and may be shared, whether they are shown right now or not */ + @JvmStatic + fun candidates(hud: Hud): List> = + hud.tree?.map?.values.orEmpty().filterIsInstance>().filter(::isShareable) + + /** The mod options that every HUD of [huds] has in the same form and that are shown for all of them, in the order of the first HUD */ + @JvmStatic + fun find(huds: List): List { + val first = huds.firstOrNull() ?: return emptyList() + val out = ArrayList() + for (prop in candidates(first)) { + val id = prop.id ?: continue + val props = huds.map { hud -> hud.tree?.getProp(id)?.takeIf(::isShareable) } + if (props.any { it == null }) continue + val all = props.filterNotNull() + if (all.any { it.display == Property.Display.HIDDEN }) continue + if (all.any { !matches(prop, it) }) continue + out.add(SharedProperty(id, all)) + } + return out + } + + /** + * A property that shows the first HUD's value and writes every HUD's own property when it is set + * + * Each HUD is written through its property rather than its field so the option's callbacks run, and + * [onWrite] runs once they all have + */ + @JvmStatic + @Suppress("UNCHECKED_CAST") + fun proxy(shared: SharedProperty, onWrite: () -> Unit = {}): Property { + val first = shared.props.first() as Property + val proxy = Properties.functional( + Supplier { first.get() }, + { value: Any? -> + Snapshot.withMutableSnapshot { + for (prop in shared.props) { + (prop as Property).setAs(Config.copyDefault(prop.type, value)) + } + } + onWrite() + }, + first.id, + first.title, + first.description, + first.type as Class, + ) + first.getMetadata()?.let { proxy.addMetadata(it) } + proxy.addDisplayCondition(Supplier { worstDisplay(shared) }) + return proxy + } + + /** Hidden when it is for any of the HUDs and disabled when it is for any of them, which is how the row should read */ + @JvmStatic + fun worstDisplay(shared: SharedProperty): Property.Display { + val displays = shared.props.map { it.display } + return when { + Property.Display.HIDDEN in displays -> Property.Display.HIDDEN + Property.Display.DISABLED in displays -> Property.Display.DISABLED + else -> Property.Display.SHOWN + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt index b9e59342b..e6b53cddb 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt @@ -62,13 +62,13 @@ enum class Modifiers(val icon: String) { Underline("underline"); } -private enum class ShadowMode(val title: String) { +internal enum class ShadowMode(val title: String) { Off("Off"), Normal("Normal"), Full("Full"); } -private enum class MergeMode(val title: String) { +internal enum class MergeMode(val title: String) { Off("Off"), Normal("Normal"), Diagonal("Diagonal"); @@ -142,49 +142,19 @@ fun Style(hud: Hud?, query: String) { third = { HudSettingTarget(hud, "textBold") { LabeledControl("Modifiers") { - Box( - modifier = Modifier - .fillMaxWidth() - .background(LocalTheme.current.componentBackground, LocalTheme.current.buttonShape) - .border(1.dp, LocalTheme.current.borderColor, LocalTheme.current.buttonShape) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - SelectableIconButton( - Modifiers.Bold.icon, - selected = textBold, - modifier = Modifier.size(18.dp), - onClick = { - Snapshot.withMutableSnapshot { - textBold = !textBold; hud.textBold = textBold - } - } - ) - SelectableIconButton( - Modifiers.Italic.icon, - selected = textItalic, - modifier = Modifier.size(18.dp), - onClick = { - Snapshot.withMutableSnapshot { - textItalic = !textItalic; hud.textItalic = textItalic - } - } - ) - SelectableIconButton( - Modifiers.Underline.icon, - selected = textUnderline, - modifier = Modifier.size(18.dp), - onClick = { - Snapshot.withMutableSnapshot { - textUnderline = !textUnderline; hud.textUnderline = textUnderline - } - } - ) - } - } + ModifierButtons( + listOf( + ModifierToggle(Modifiers.Bold.icon, textBold) { + Snapshot.withMutableSnapshot { textBold = !textBold; hud.textBold = textBold } + }, + ModifierToggle(Modifiers.Italic.icon, textItalic) { + Snapshot.withMutableSnapshot { textItalic = !textItalic; hud.textItalic = textItalic } + }, + ModifierToggle(Modifiers.Underline.icon, textUnderline) { + Snapshot.withMutableSnapshot { textUnderline = !textUnderline; hud.textUnderline = textUnderline } + }, + ), + ) } } }, @@ -363,9 +333,38 @@ fun Style(hud: Hud?, query: String) { } } +internal class ModifierToggle(val icon: String, val selected: Boolean, val onToggle: () -> Unit) + +/** A row of icon switches that light up while on */ +@Composable +internal fun ModifierButtons(toggles: List) { + val theme = LocalTheme.current + Box( + modifier = Modifier + .fillMaxWidth() + .background(theme.componentBackground, theme.buttonShape) + .border(1.dp, theme.borderColor, theme.buttonShape) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + for (toggle in toggles) { + SelectableIconButton( + toggle.icon, + selected = toggle.selected, + modifier = Modifier.size(18.dp), + onClick = toggle.onToggle, + ) + } + } + } +} + /** [ColorPicker] for a HUD colour stored as separate colour, chroma and speed fields */ @Composable -private fun ColorSwatch( +internal fun ColorSwatch( color: Color, chroma: Boolean = false, chromaSpeed: Float = 1f, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt index ce951b422..bf1857c75 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt @@ -1269,12 +1269,34 @@ fun HudConfigScreen(tree: Tree, searchQuery: String) { key(System.identityHashCode(tree)) { HudConfigScreenContent(tree, searchQuery) } } +/** One option laid out the way [HudConfigScreen] lays out the options of a HUD */ +@Composable +fun HudOptionRow(prop: Property<*>) { + CompositionLocalProvider(LocalOptionWidth provides 220.dp) { + SettingRow(prop, compact = isWideControl(prop)) + } +} + @Composable private fun HudConfigScreenContent(tree: Tree, searchQuery: String) { val categories = remember(tree) { buildCategories(tree) } + HudCategoryList(categories, searchQuery) +} + +/** + * The category chips and rows of [categories], laid out the way the HUD editor lays out the options of a HUD + * + * [row] draws one entry, which a caller replaces to draw its own options + */ +@Composable +internal fun HudCategoryList( + categories: List, + searchQuery: String, + row: @Composable (ConfigListEntry) -> Unit = { ConfigListRow(it, ::hasWideControl) }, +) { val revision = rememberDisplayRevision(categories) val visibleCategories = remember(categories, revision) { categories.filter { filterHiddenNodes(it) != null } } - var chosenCategory by remember(tree) { mutableStateOf(null) } + var chosenCategory by remember(categories) { mutableStateOf(null) } val selectedCategory = chosenCategory?.takeIf { it in visibleCategories } ?: visibleCategories.firstOrNull() val entries = remember(visibleCategories, selectedCategory, searchQuery, revision) { if (searchQuery.isEmpty()) { @@ -1305,7 +1327,7 @@ private fun HudConfigScreenContent(tree: Tree, searchQuery: String) { } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - entries.forEach { entry -> ConfigListRow(entry, ::hasWideControl) } + entries.forEach { entry -> row(entry) } } } } diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistoryTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistoryTest.java index b1b35f097..13c035524 100644 --- a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistoryTest.java +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/hud/screens/HudHistoryTest.java @@ -26,14 +26,23 @@ package org.polyfrost.oneconfig.internal.ui.hud.screens; +import java.util.Arrays; +import java.util.List; import kotlin.Pair; +import kotlin.Unit; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.polyfrost.oneconfig.api.config.v1.ConfigManager; +import org.polyfrost.oneconfig.api.config.v1.Property; import org.polyfrost.oneconfig.api.hud.v1.Hud; import org.polyfrost.oneconfig.api.hud.v1.HudManager; import org.polyfrost.oneconfig.api.hud.v1.TextHud; +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedAmount; +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedColor; +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedOptions; +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedProperty; +import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.SharedToggle; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -179,6 +188,55 @@ void addedHudIsRemovedOnUndoAndComesBackOnRedo() { assertNotNull(HudManager.INSTANCE.instanceById(id)); } + @Test + void oneEditToSeveralHudsIsOneStep() { + Hud first = place(); + Hud second = place(); + List both = Arrays.asList(first, second); + float padding = first.getPadLeft(); + boolean background = first.getShowBackground(); + history.poll(false); + + ((SharedToggle) SharedOptions.find("showBackground")).apply(both, !background); + ((SharedAmount) SharedOptions.find("padLeft")).apply(both, 6f); + ((SharedColor) SharedOptions.find("textColor")).apply(both, 0xFF112233); + settle(); + + assertNotNull(history.undo()); + for (Hud hud : both) { + assertEquals(background, hud.getShowBackground()); + assertEquals(padding, hud.getPadLeft(), 0.001f); + assertEquals(0xFFFFFFFF, hud.getTextColor()); + } + assertNull(history.undo(), "every HUD went back in the one step"); + + assertNotNull(history.redo()); + for (Hud hud : both) { + assertEquals(!background, hud.getShowBackground()); + assertEquals(6f, hud.getPadLeft(), 0.001f); + assertEquals(0xFF112233, hud.getTextColor()); + } + assertNull(history.redo()); + } + + @Test + void oneEditThroughASharedPropertyIsOneStep() { + Hud first = place(); + Hud second = place(); + List both = Arrays.asList(first, second); + history.poll(false); + List> props = Arrays.asList(first.getTree().getProp("brackets"), second.getTree().getProp("brackets")); + Property proxy = new SharedProperty("brackets", props).proxy(() -> Unit.INSTANCE); + + proxy.set(true); + settle(); + + for (Hud hud : both) assertEquals(true, ((TextHud) hud).getBrackets()); + assertNotNull(history.undo()); + for (Hud hud : both) assertEquals(false, ((TextHud) hud).getBrackets()); + assertNull(history.undo(), "both HUDs went back in the one step"); + } + private void settle() { history.poll(false); history.poll(false);