From eef79c1b39d673b854b5d988a18965517803d3d1 Mon Sep 17 00:00:00 2001 From: Tellinq <59412384+Tellinq@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:05:43 -0500 Subject: [PATCH] fix(hud): make undo and redo step through one action at a time Options holding arrays, like colors, never compared equal to their own snapshot, so edits were never recorded and undo jumped back to the editor opening. Quick back-to-back drags were also merged into a single step. --- .../internal/ui/hud/screens/HudHistory.kt | 22 +++- .../ui/hud/screens/HudHistoryTest.java | 121 ++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) 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..30faeb964 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,8 +43,15 @@ 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 = - if (a is Float && b is Float) abs(a - b) < FLOAT_TOLERANCE else a == b +private fun sameValue(a: Any?, b: Any?): Boolean = when { + a is Float && b is Float -> abs(a - b) < FLOAT_TOLERANCE + a is Map<*, *> && b is Map<*, *> -> + a.size == b.size && a.all { (key, value) -> b.containsKey(key) && sameValue(value, b[key]) } + a is List<*> && b is List<*> -> a.size == b.size && a.indices.all { sameValue(a[it], b[it]) } + a is Array<*> && b is Array<*> -> sameValue(a.asList(), b.asList()) + a != null && b != null && a.javaClass.isArray && b.javaClass.isArray -> java.util.Objects.deepEquals(a, b) + else -> a == b +} private fun sameLayout(a: Layout, b: Layout): Boolean { if (a.owner !== b.owner || a.huds.size != b.huds.size) return false @@ -188,12 +195,15 @@ internal class HudHistory { private val redoStack = ArrayDeque() private var recorded: Layout? = null private var settling: Layout? = null + private var pending: Layout? = null + private var wasBusy = false private fun reset(now: Layout) { undoStack.clear() redoStack.clear() recorded = now settling = null + pending = null } private fun record(now: Layout) { @@ -202,6 +212,7 @@ internal class HudHistory { redoStack.clear() recorded = now settling = null + pending = null } private fun unrecorded(): Boolean = recorded?.let { !sameLayout(it, capture()) } == true @@ -218,10 +229,16 @@ internal class HudHistory { reset(now) return } + val gestureStarted = busy && !wasBusy + wasBusy = busy + if (gestureStarted) { + pending?.let(::record) + } if (busy || sameLayout(last, now)) { settling = null return } + pending = now val previous = settling if (previous != null && sameLayout(previous, now)) record(now) else settling = now } @@ -244,6 +261,7 @@ internal class HudHistory { val revived = restore(target) recorded = capture() settling = null + pending = null return revived } } 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..4c869033c 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 @@ -31,9 +31,12 @@ 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.annotations.Color; import org.polyfrost.oneconfig.api.hud.v1.Hud; +import org.polyfrost.oneconfig.api.hud.v1.HudAnchor; import org.polyfrost.oneconfig.api.hud.v1.HudManager; import org.polyfrost.oneconfig.api.hud.v1.TextHud; +import org.polyfrost.compose.render.PolyColor; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -179,6 +182,121 @@ void addedHudIsRemovedOnUndoAndComesBackOnRedo() { assertNotNull(HudManager.INSTANCE.instanceById(id)); } + @Test + void eachActionIsItsOwnStep() { + Hud hud = place(); + history.poll(false); + + hud.setAbsolutePosition(100f, 60f); + settle(); + hud.setAbsolutePosition(200f, 120f); + settle(); + hud.setTextColor(0xFF112233); + settle(); + + assertNotNull(history.undo()); + assertEquals(0xFFFFFFFF, hud.getTextColor()); + assertEquals(200f, hud.getX(), 0.01f); + + assertNotNull(history.undo()); + assertEquals(100f, hud.getX(), 0.01f); + + assertNotNull(history.redo()); + assertEquals(200f, hud.getX(), 0.01f); + assertEquals(0xFFFFFFFF, hud.getTextColor()); + } + + @Test + void untouchedColorOptionIsNotAChange() { + Hud hud = place(); + history.poll(false); + settle(); + + assertNull(history.undo()); + + hud.setAbsolutePosition(200f, 120f); + settle(); + assertNotNull(history.undo()); + assertEquals(10f, hud.getX(), 0.01f); + assertNull(history.undo()); + } + + @Test + void colorChangeIsItsOwnStep() { + TestHud hud = (TestHud) place(); + history.poll(false); + + hud.setAbsolutePosition(100f, 60f); + settle(); + hud.accent = new PolyColor(0xFF112233); + settle(); + + assertNotNull(history.undo()); + assertEquals(0xFF2B4BFF, hud.accent.getArgb()); + assertEquals(100f, hud.getX(), 0.01f); + } + + @Test + void movingAnchoredHudIsOneStep() { + Hud parent = place(); + Hud child = place(); + child.setAbsolutePosition(100f, 100f); + child.anchorTo(parent, HudAnchor.BottomRight); + history.poll(false); + float x = child.getX(); + float y = child.getY(); + + child.setAbsolutePosition(x + 50f, y + 30f); + settle(); + child.setAbsolutePosition(x + 90f, y + 70f); + settle(); + + assertNotNull(history.undo()); + assertEquals(x + 50f, child.getX(), 0.01f); + assertEquals(y + 30f, child.getY(), 0.01f); + assertNotNull(history.undo()); + assertEquals(x, child.getX(), 0.01f); + assertEquals(y, child.getY(), 0.01f); + } + + @Test + void movingAnchorTargetIsOneStep() { + Hud parent = place(); + Hud child = place(); + child.setAbsolutePosition(100f, 100f); + child.anchorTo(parent, HudAnchor.BottomRight); + history.poll(false); + + parent.setAbsolutePosition(60f, 60f); + settle(); + parent.setAbsolutePosition(120f, 90f); + settle(); + + assertNotNull(history.undo()); + assertEquals(60f, parent.getX(), 0.01f); + assertNotNull(history.undo()); + assertEquals(10f, parent.getX(), 0.01f); + } + + @Test + void gestureStartedBeforeTheLastSettledIsItsOwnStep() { + Hud hud = place(); + history.poll(false); + + hud.setAbsolutePosition(100f, 60f); + history.poll(true); + history.poll(false); + hud.setAbsolutePosition(200f, 120f); + history.poll(true); + history.poll(true); + settle(); + + assertNotNull(history.undo()); + assertEquals(100f, hud.getX(), 0.01f); + assertNotNull(history.undo()); + assertEquals(10f, hud.getX(), 0.01f); + } + private void settle() { history.poll(false); history.poll(false); @@ -192,6 +310,9 @@ private Hud place() { } static class TestHud extends TextHud { + @Color(title = "Accent") + public PolyColor accent = new PolyColor(0xFF2B4BFF); + TestHud() { super("test-history", "Test History HUD", Hud.Category.getINFO(), "", ""); }