diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/config/FieldType.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/config/FieldType.kt index 4075d0014..2486e5490 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/config/FieldType.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/config/FieldType.kt @@ -1,6 +1,5 @@ package net.kernelpanicsoft.archie.config -import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.MapSerializer @@ -8,10 +7,12 @@ import kotlinx.serialization.builtins.serializer import kotlinx.serialization.serializer import net.kernelpanicsoft.archie.serialization.DeferredListSerializer import net.kernelpanicsoft.archie.serialization.DeferredMapSerializer +import net.kernelpanicsoft.archie.serialization.SerializationManager import net.kernelpanicsoft.archie.serialization.serializers.ColorSerializer import net.kernelpanicsoft.archie.serialization.serializers.ResourceLocationSerializer import net.minecraft.resources.ResourceLocation import kotlin.reflect.KClass +import kotlin.reflect.full.createType /** * Tags a [DataSpec] field with its runtime type and the [KSerializer] used to read/write it, @@ -81,14 +82,16 @@ internal sealed class FieldType data class EnumSelector>(val kClass: KClass) : FieldType() { - @OptIn(InternalSerializationApi::class) - override val serializer: KSerializer = kClass.serializer() + // Module-aware, unlike the bare KClass.serializer() reflective lookup - matters if T + // (or a field of it) ever needs a contextual serializer registered via SerializationManager. + @Suppress("UNCHECKED_CAST") + override val serializer: KSerializer = SerializationManager.module.serializer(kClass.createType()) as KSerializer } data class Selector(val kClass: KClass) : FieldType() { - @OptIn(InternalSerializationApi::class) - override val serializer: KSerializer = kClass.serializer() + @Suppress("UNCHECKED_CAST") + override val serializer: KSerializer = SerializationManager.module.serializer(kClass.createType()) as KSerializer } data object IntList : FieldType>() diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt index 2212be8ab..5f82cd7d1 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt @@ -13,21 +13,26 @@ import net.kernelpanicsoft.archie.gui.access.SlotHighlightClipProvider import net.kernelpanicsoft.archie.gui.access.SlotLayerDepthProvider import net.kernelpanicsoft.archie.gui.blockentity.LocalBlockEntityState import net.kernelpanicsoft.archie.gui.composables.containers.RootContainer +import net.kernelpanicsoft.archie.gui.focus.collectFocusableChildren import net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu import net.kernelpanicsoft.archie.gui.item.LocalItemState +import net.kernelpanicsoft.archie.gui.layer.Layer import net.kernelpanicsoft.archie.gui.layer.LayerStackManager import net.kernelpanicsoft.archie.gui.layer.LocalLayerManager +import net.kernelpanicsoft.archie.gui.layer.LocalLayerManagerOrNull import net.kernelpanicsoft.archie.gui.layout.IntCoordinates import net.kernelpanicsoft.archie.gui.layout.IntRect import net.kernelpanicsoft.archie.gui.layout.LayoutNode import net.kernelpanicsoft.archie.gui.modifiers.Constraints import net.kernelpanicsoft.archie.gui.modifiers.input.PointerEventType +import net.kernelpanicsoft.archie.gui.theme.LocalTheme import net.kernelpanicsoft.archie.gui.util.extension.processCharEvent import net.kernelpanicsoft.archie.gui.util.extension.processDragEvent import net.kernelpanicsoft.archie.gui.util.extension.processKeyEvent import net.kernelpanicsoft.archie.gui.util.extension.processPointerEvent import net.kernelpanicsoft.archie.gui.util.extension.processScrollEvent import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.components.events.GuiEventListener import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen import net.minecraft.network.chat.Component import net.minecraft.world.entity.player.Inventory @@ -113,6 +118,9 @@ abstract class ComposeContainerScreen>( private var lastMouseX = 0.0 private var lastMouseY = 0.0 + /** The layer [render] last ran [setInitialFocus] for - see its use there. */ + private var lastTopLayer: Layer? = null + override fun isComposeIdle(): Boolean = !applyScheduled && !hasFrameWaiters && recomposeJob?.isActive != true @@ -141,14 +149,13 @@ abstract class ComposeContainerScreen>( */ protected fun start(content: @Composable () -> Unit) { recomposer = Recomposer(coroutineContext) - layerManager = LayerStackManager(recomposer) - - AUIScopeManager.scopes += composeScope - launch { recomposer.runRecomposeAndApplyChanges() } - - layerManager.push { _ -> + layerManager = LayerStackManager(recomposer) { layerContent -> + // Applied to every layer this screen ever pushes (base, modal, dropdown, tooltip + // alike) - see LayerStackManager's screenLocals doc for why a plain + // CompositionLocalProvider wrapping only this start() call wouldn't reach them. CompositionLocalProvider( LocalContainerScreen provides this, + LocalVanillaScreen provides this, LocalContainerMenu provides menu, LocalSlotData provides menu.slotData, // Only one of these is non-null for any given menu - LocalBlockEntityState / @@ -158,12 +165,29 @@ abstract class ComposeContainerScreen>( LocalBlockEntityState provides (menu as? ComposeBlockContainerMenu<*, *>)?.blockEntityState, LocalItemState provides (menu as? ComposeItemContainerMenu<*>)?.itemState, LocalLayerManager provides layerManager, + LocalLayerManagerOrNull provides layerManager, ) { - RootContainer { - content() + // Re-supplies whatever Theme{} is currently mounted in the base layer (see + // LayerStackManager.rootTheme) so a later-pushed layer isn't stuck with + // LocalTheme's own default - it's a separate top-level composition, so it'd + // never otherwise see a Theme{} that only wraps the base layer's own content. + val theme = layerManager.rootTheme + if (theme != null) { + CompositionLocalProvider(LocalTheme provides theme) { layerContent() } + } else { + layerContent() } } } + + AUIScopeManager.scopes += composeScope + launch { recomposer.runRecomposeAndApplyChanges() } + + layerManager.push { _ -> + RootContainer { + content() + } + } } // ── Rendering ───────────────────────────────────────────────────────── @@ -247,6 +271,14 @@ abstract class ComposeContainerScreen>( { renderNodes(false, guiGraphics, mouseX, mouseY, partialTick) } + + // See ComposeScreen.renderNodes for why this only runs when the top layer actually + // changed (a modal opening or closing), not every frame. + if (layerManager.top !== lastTopLayer) { + lastTopLayer = layerManager.top + clearFocus() + setInitialFocus() + } } override fun isHovering( @@ -376,6 +408,14 @@ abstract class ComposeContainerScreen>( private fun getTopNode(): LayoutNode? = layerManager.top?.rootNode + // See ComposeScreen.children() for why this one override is enough to bridge Compose's + // `Modifier.focusable` nodes into vanilla's Tab/Shift-Tab/arrow-key navigation and any + // other GuiEventListener-walking consumer (e.g. Controlify). + override fun children(): List { + val topNode = getTopNode() ?: return super.children() + return collectFocusableChildren(topNode) + } + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { val topNode = getTopNode() ?: return super.mouseClicked(mouseX, mouseY, button) processPointerEvent(topNode, mouseX, mouseY, PointerEventType.GLOBAL_PRESS, true) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeScreen.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeScreen.kt index e4ebdbfe7..1cc0456f6 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeScreen.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeScreen.kt @@ -4,9 +4,13 @@ import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.Snapshot import com.mojang.blaze3d.platform.InputConstants import kotlinx.coroutines.* +import net.kernelpanicsoft.archie.gui.focus.collectFocusableChildren +import net.kernelpanicsoft.archie.gui.layer.Layer import net.kernelpanicsoft.archie.gui.layer.LayerStackManager import net.kernelpanicsoft.archie.gui.layer.LocalLayerManager +import net.kernelpanicsoft.archie.gui.layer.LocalLayerManagerOrNull import net.kernelpanicsoft.archie.gui.layout.* +import net.kernelpanicsoft.archie.gui.theme.LocalTheme import net.kernelpanicsoft.archie.gui.modifiers.Constraints import net.kernelpanicsoft.archie.gui.modifiers.Modifier import net.kernelpanicsoft.archie.gui.modifiers.fillMaxSize @@ -17,6 +21,7 @@ import net.kernelpanicsoft.archie.gui.util.extension.processKeyEvent import net.kernelpanicsoft.archie.gui.util.extension.processPointerEvent import net.kernelpanicsoft.archie.gui.util.extension.processScrollEvent import net.minecraft.client.gui.GuiGraphics +import net.minecraft.client.gui.components.events.GuiEventListener import net.minecraft.client.gui.screens.Screen import net.minecraft.network.chat.Component import org.lwjgl.glfw.GLFW @@ -26,6 +31,21 @@ import kotlin.coroutines.CoroutineContext val LocalScreen: ProvidableCompositionLocal = compositionLocalOf { throw IllegalStateException("Screen has not been provided") } +/** + * Provides the hosting vanilla [Screen], regardless of whether it's a [ComposeScreen] or a + * [ComposeContainerScreen] - unlike [LocalScreen]/`LocalContainerScreen`, which are mutually + * exclusive depending on the screen type, this is provided by both. + * + * `Screen.setFocused`/`getFocused`/`clearFocus` are all public vanilla API (unlike + * `setInitialFocus`/`changeFocus`, which are `protected`), so composables that need to register + * or release vanilla keyboard/controller focus explicitly - e.g. a pointer-driven text field + * syncing its local focus state back to vanilla, so Tab navigation and a modal opening over it + * both stay consistent with what's actually focused - can reach them through this without + * needing a reference to the concrete screen subclass. + */ +val LocalVanillaScreen: ProvidableCompositionLocal = + compositionLocalOf { throw IllegalStateException("Screen has not been provided") } + /** * Implemented by Compose-driven screens that recompose asynchronously, so test harnesses can * poll for a settled frame (no pending or in-flight recomposition) before asserting on rendered @@ -141,6 +161,9 @@ abstract class ComposeScreen( private var lastMouseX = Double.NEGATIVE_INFINITY private var lastMouseY = Double.NEGATIVE_INFINITY + /** The layer [renderNodes] last ran [setInitialFocus] for - see its use there. */ + private var lastTopLayer: Layer? = null + // `Recomposer.hasPendingWork` is Compose's own atomically-maintained "is there recomposition, // apply-changes, or effect work outstanding" signal - the same one Compose's own test tooling // (ComposeTestRule.waitForIdle()) uses. Reimplementing this by hand via applyScheduled/ @@ -162,21 +185,37 @@ abstract class ComposeScreen( */ protected fun start(content: @Composable () -> Unit) { recomposer = Recomposer(coroutineContext) - layerManager = LayerStackManager(recomposer) - - AUIScopeManager.scopes += composeScope - launch { recomposer.runRecomposeAndApplyChanges() } - - layerManager.push { _ -> + layerManager = LayerStackManager(recomposer) { layerContent -> + // Applied to every layer this screen ever pushes (base, modal, dropdown, tooltip + // alike) - see LayerStackManager's screenLocals doc for why a plain + // CompositionLocalProvider wrapping only this start() call wouldn't reach them. CompositionLocalProvider( LocalScreen provides this, + LocalVanillaScreen provides this, LocalLayerManager provides layerManager, + LocalLayerManagerOrNull provides layerManager, ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - content() + // Re-supplies whatever Theme{} is currently mounted in the base layer (see + // LayerStackManager.rootTheme) so a later-pushed layer isn't stuck with + // LocalTheme's own default - it's a separate top-level composition, so it'd + // never otherwise see a Theme{} that only wraps the base layer's own content. + val theme = layerManager.rootTheme + if (theme != null) { + CompositionLocalProvider(LocalTheme provides theme) { layerContent() } + } else { + layerContent() } } } + + AUIScopeManager.scopes += composeScope + launch { recomposer.runRecomposeAndApplyChanges() } + + layerManager.push { _ -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + content() + } + } } // ── Rendering ───────────────────────────────────────────────────────── @@ -221,7 +260,19 @@ abstract class ComposeScreen( hasFrameWaiters = false recomposeJob = composeScope.launch { clock.sendFrame(System.nanoTime()) } } - setInitialFocus() + + // setInitialFocus() re-runs vanilla's own Tab-navigation search (nextFocusPath) to find + // something to focus, which visits whatever's already focused first - calling it every + // frame while nothing changed would auto-advance focus to the next candidate each frame + // (indistinguishable, to vanilla, from a real Tab press) whenever the keyboard was the + // last input type. Only re-run it when the top layer actually changed - a modal opening + // or closing - and clear the old focus first, since a modal opening on top otherwise + // leaves the base screen's element (now hidden behind it) marked focused indefinitely. + if (layerManager.top !== lastTopLayer) { + lastTopLayer = layerManager.top + clearFocus() + setInitialFocus() + } } override fun render(guiGraphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { @@ -270,6 +321,18 @@ abstract class ComposeScreen( private fun topNode() = layerManager.top?.rootNode + // Bridges Compose's own focus concept (`Modifier.focusable`) into vanilla's built-in + // GuiEventListener focus graph. Screen already implements Tab/Shift-Tab/arrow-key + // navigation, focus tracking (getFocused/setFocused) and ComponentPath-based dispatch + // entirely in terms of `children()` - overriding just this one method is enough to make + // all of that (plus anything else that walks GuiEventListener, e.g. Controlify's + // controller navigation) reach Compose content. Scoped to the top layer only, matching + // topNode()'s modal-aware input dispatch: a modal's focus stays within the modal. + override fun children(): List { + val top = topNode() ?: return super.children() + return collectFocusableChildren(top) + } + override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean { val top = topNode() ?: return super.mouseClicked(mouseX, mouseY, button) processPointerEvent(top, mouseX, mouseY, PointerEventType.GLOBAL_PRESS, global = true) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/animation/Animation.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/animation/Animation.kt index 71af0cec7..3774dc7a6 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/animation/Animation.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/animation/Animation.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -91,4 +92,46 @@ fun animateInt(targetValue: Int, spec: AnimationSpec = AnimationSpec()): Int { return animatedFloat.roundToInt() } +/** + * Sweeps from [from] to [to] every time [key] changes, holding at [to] otherwise - a one-shot + * "pulse"/"pop" effect for a discrete event (a checkbox toggling, a radio button being + * selected) rather than the continuous value [animateFloat]/[animateInt] track toward a moving + * target. Pair with [Easings.OutBack] for a satisfying overshoot-then-settle bounce. + * + * Does not pulse on the composable's initial composition - only on a later change of [key]. + */ +@Composable +fun animatePulse(key: Any?, from: Float = 0.8f, to: Float = 1f, spec: AnimationSpec = AnimationSpec()): Float { + var value by remember { mutableFloatStateOf(to) } + var initialized by remember { mutableStateOf(false) } + + LaunchedEffect(key) { + if (!initialized) { + initialized = true + return@LaunchedEffect + } + + val duration = spec.durationMillis + if (duration <= 0.milliseconds) { + value = to + return@LaunchedEffect + } + + value = from + val startTime = withFrameNanos { it } + var frameTime = startTime + var rawProgress: Float + do { + val elapsedNanos = frameTime - startTime + rawProgress = (elapsedNanos / (duration.inWholeMilliseconds * 1_000_000f)).coerceIn(0f, 1f) + value = from + (to - from) * spec.easing.transform(rawProgress) + frameTime = withFrameNanos { it } + } while (rawProgress < 1f) + + value = to + } + + return value +} + diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/blockentity/BlockEntityStateComposables.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/blockentity/BlockEntityStateComposables.kt index c84610858..3d0f5f803 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/blockentity/BlockEntityStateComposables.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/blockentity/BlockEntityStateComposables.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import kotlinx.serialization.serializer +import net.kernelpanicsoft.archie.serialization.SerializationManager /** * Provides the current block entity state to composables in the composition tree. @@ -40,5 +41,5 @@ inline fun observeProperty( initialValue: T? = null, ): MutableState { val state = LocalBlockEntityState.current ?: throw RuntimeException("No block entity state available in composition") - return state.observeProperty(propertyName, serializer(), initialValue) + return state.observeProperty(propertyName, SerializationManager.module.serializer(), initialValue) } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Collapsible.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Collapsible.kt index 721d8d5ff..a8111c86b 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Collapsible.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Collapsible.kt @@ -121,7 +121,31 @@ fun Collapsible( } }, ) { - Row(horizontalArrangement = Arrangement.spacedBy(5)) { + // A plain Row can't be used here: this whole subtree is measured with an + // unbounded maxHeight (see the measurePolicy above, which needs the content's + // natural height for the expand animation), so the separator's fillMaxHeight() + // would fill that unbounded height instead of matching its sibling - producing a + // separator (and this Row's own reported height) sized in the billions of pixels. + // Measuring content first and constraining the separator to its exact height + // sidesteps that: fillMaxHeight() then fills *this* bounded height correctly. + Layout( + name = "Row", + measurePolicy = { _, measurables, constraints -> + val (separator, box) = measurables + val boxPlaceable = box.measure(constraints.copy(minHeight = 0)) + val separatorPlaceable = separator.measure( + constraints.copy(minHeight = boxPlaceable.height, maxHeight = boxPlaceable.height) + ) + val spacing = 5 + MeasureResult( + separatorPlaceable.width + spacing + boxPlaceable.width, + maxOf(separatorPlaceable.height, boxPlaceable.height), + ) { + separatorPlaceable.placeAt(0, 0) + boxPlaceable.placeAt(separatorPlaceable.width + spacing, 0) + } + }, + ) { Spacer( modifier = Modifier .then(PaddingModifier(PaddingValues(left = 5))) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Scrollable.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Scrollable.kt index a343d5367..379c8a994 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Scrollable.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Scrollable.kt @@ -3,6 +3,8 @@ package net.kernelpanicsoft.archie.gui.composables.containers import androidx.compose.runtime.* import net.kernelpanicsoft.archie.gui.LocalSlotClipBounds import net.kernelpanicsoft.archie.gui.SlotClipSource +import net.kernelpanicsoft.archie.gui.focus.BringIntoViewParent +import net.kernelpanicsoft.archie.gui.focus.LocalBringIntoViewParent import net.kernelpanicsoft.archie.gui.layout.* import net.kernelpanicsoft.archie.gui.modifiers.Constraints import net.kernelpanicsoft.archie.gui.modifiers.Modifier @@ -112,6 +114,34 @@ fun Scrollable( ) { val clipSource = remember { SlotClipSource() } + // Lets a focused descendant (see focusable()'s vanilla-focus bridge) scroll itself into + // view - reuses clipSource's already-tracked absolute viewport bounds rather than tracking + // origin/size separately. + val bringIntoViewParent = remember(direction, state) { + BringIntoViewParent { target -> + val bounds = clipSource.bounds ?: return@BringIntoViewParent + val containerStart: Int + val containerSize: Int + val targetStart: Int + val targetSize: Int + if (direction == ScrollDirection.VERTICAL) { + containerStart = bounds.minY; containerSize = bounds.height + targetStart = target.absoluteCoords.y; targetSize = target.height + } else { + containerStart = bounds.minX; containerSize = bounds.width + targetStart = target.absoluteCoords.x; targetSize = target.width + } + val relativeStart = targetStart - containerStart + val relativeEnd = relativeStart + targetSize + val delta = when { + relativeStart < 0 -> relativeStart + relativeEnd > containerSize -> relativeEnd - containerSize + else -> 0 + } + if (delta != 0) state.scrollBy(delta.toDouble()) + } + } + val measurePolicy = remember(direction) { object : MeasurePolicy { override fun measure( @@ -154,7 +184,7 @@ fun Scrollable( } } - CompositionLocalProvider(LocalSlotClipBounds provides clipSource) { + CompositionLocalProvider(LocalSlotClipBounds provides clipSource, LocalBringIntoViewParent provides bringIntoViewParent) { Layout( name = "Scrollable", measurePolicy = measurePolicy, diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Surface.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Surface.kt index 44ceb8f7e..9d3d03a14 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Surface.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/Surface.kt @@ -9,11 +9,10 @@ import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.Renderer import net.kernelpanicsoft.archie.gui.modifiers.Modifier import net.kernelpanicsoft.archie.gui.modifiers.debug -import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.gui.nodes.UINode import net.kernelpanicsoft.archie.gui.theme.LocalTheme -import net.kernelpanicsoft.archie.gui.theme.SimpleThemeState import net.kernelpanicsoft.archie.gui.theme.ThemeVariants +import net.kernelpanicsoft.archie.gui.theme.intrinsicSizeModifier import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.client.gui.GuiGraphics @@ -62,16 +61,7 @@ fun Surface( drawThemeState(state, x, y, node.width, node.height) } }, - modifier = Modifier.debug(state.texture.toString()).apply { - if (!composableTheme.isNineslice) { - with(composableTheme.states[TextureStates.DEFAULT] as SimpleThemeState) { - sizeIn( - minWidth = width, - minHeight = height - ) - } - } - } then modifier, + modifier = Modifier.debug(state.texture.toString()).then(composableTheme.intrinsicSizeModifier()) then modifier, content = content ) } \ No newline at end of file diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/TabContainer.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/TabContainer.kt index f9ff390f7..51169d9e2 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/TabContainer.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/containers/TabContainer.kt @@ -6,11 +6,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import net.kernelpanicsoft.archie.gui.animation.AnimationSpec +import net.kernelpanicsoft.archie.gui.animation.animateInt import net.kernelpanicsoft.archie.gui.composables.basic.Text import net.kernelpanicsoft.archie.gui.composables.basic.Texture -import net.kernelpanicsoft.archie.gui.composables.input.ButtonCore -import net.kernelpanicsoft.archie.gui.composables.theme.TextureStates import net.kernelpanicsoft.archie.gui.composables.theme.WidgetState +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsPressedAsState import net.kernelpanicsoft.archie.gui.layout.Alignment import net.kernelpanicsoft.archie.gui.layout.Arrangement import net.kernelpanicsoft.archie.gui.layout.BoxMeasurePolicy @@ -20,19 +24,21 @@ import net.kernelpanicsoft.archie.gui.layout.Renderer import net.kernelpanicsoft.archie.gui.layout.Row import net.kernelpanicsoft.archie.gui.layout.dp import net.kernelpanicsoft.archie.gui.modifiers.Modifier +import net.kernelpanicsoft.archie.gui.modifiers.input.selectable import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.gui.modifiers.position.padding import net.kernelpanicsoft.archie.gui.modifiers.position.offset import net.kernelpanicsoft.archie.gui.modifiers.position.zIndex import net.kernelpanicsoft.archie.gui.nodes.UINode import net.kernelpanicsoft.archie.gui.theme.LocalTheme -import net.kernelpanicsoft.archie.gui.theme.SimpleThemeState import net.kernelpanicsoft.archie.gui.theme.ThemeVariants +import net.kernelpanicsoft.archie.gui.theme.intrinsicSizeModifier import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.network.chat.Component import net.minecraft.resources.ResourceLocation import net.minecraft.client.gui.GuiGraphics +import kotlin.time.Duration.Companion.milliseconds /** Built-in themed texture keys for [Tab]/[TabContainer], matching vanilla tab styles. */ object TabTextures @@ -340,71 +346,80 @@ fun Tab( val composableTheme = theme.getComposableTheme(texture) val measurePolicy = remember { BoxMeasurePolicy(Alignment.CenterStart) } - ButtonCore( + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val isFocused by interactionSource.collectIsFocusedAsState() + + val selectableModifier = Modifier.selectable( + selected = selected, + enabled = enabled, + interactionSource = interactionSource, onClick = { onClick(spec) }, + ) + + val stateKey = WidgetState.resolve( + composableTheme, variant, + WidgetState.clicked(selected || isPressed), WidgetState.focused(isHovered || isFocused), enabled = enabled, - modifier = modifier, - ) { isHovered, isPressed -> - val stateKey = WidgetState.resolve( - composableTheme, variant, - WidgetState.clicked(selected || isPressed), WidgetState.hovered(isHovered), - enabled = enabled, - ) - val state = composableTheme.getState(stateKey, variant) - val offsetModifier = Modifier - .zIndex(if (selected && elevateSelected) 1f else 0f) - .offset(x = 0, y = if (selected && !elevateSelected) -SELECTED_ELEVATION_PX else 0) - .padding(horizontal = 10, vertical = 6) - val sizeModifier = if (!composableTheme.isNineslice) { - val defaultState = composableTheme.states[TextureStates.DEFAULT] as SimpleThemeState - Modifier.sizeIn(minWidth = defaultState.width, minHeight = defaultState.height) - } else Modifier - - Layout( - name = "Tab", - measurePolicy = measurePolicy, - renderer = object : Renderer { - override fun render( - node: UINode, - x: Int, - y: Int, - guiGraphics: GuiGraphics, - mouseX: Int, - mouseY: Int, - partialTick: Float, - ) = guiGraphics { - node.renderState = stateKey - drawThemeState(state, x, y, node.width, node.height) - } - }, - modifier = sizeModifier.then(offsetModifier), + ) + val state = composableTheme.getState(stateKey, variant) + // Eases into/out of the selected elevation instead of snapping, matching Button's own + // press-offset animation. + val elevationOffset = animateInt( + targetValue = if (selected && !elevateSelected) -SELECTED_ELEVATION_PX else 0, + spec = AnimationSpec(durationMillis = 120.milliseconds), + ) + val offsetModifier = Modifier + .zIndex(if (selected && elevateSelected) 1f else 0f) + .offset(x = 0, y = elevationOffset) + .padding(horizontal = 10, vertical = 6) + val sizeModifier = composableTheme.intrinsicSizeModifier() + + Layout( + name = "Tab", + measurePolicy = measurePolicy, + renderer = object : Renderer { + override fun render( + node: UINode, + x: Int, + y: Int, + guiGraphics: GuiGraphics, + mouseX: Int, + mouseY: Int, + partialTick: Float, + ) = guiGraphics { + node.renderState = stateKey + drawThemeState(state, x, y, node.width, node.height) + } + }, + modifier = selectableModifier.then(sizeModifier).then(offsetModifier).then(modifier), + ) { + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(iconSpacing.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier, - horizontalArrangement = Arrangement.spacedBy(iconSpacing.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - spec.icon?.let { icon -> - Texture( - loc = icon.texture, - uOffset = icon.uOffset, - vOffset = icon.vOffset, - u = icon.regionWidth, - v = icon.regionHeight, - textureWidth = icon.textureWidth, - textureHeight = icon.textureHeight, - modifier = Modifier.sizeIn( - minWidth = icon.displayWidth, - minHeight = icon.displayHeight, - ), - ) - } - Text( - text = spec.title, - color = if (selected) theme.darkTextColor else theme.lightTextColor, - dropShadow = !selected + spec.icon?.let { icon -> + Texture( + loc = icon.texture, + uOffset = icon.uOffset, + vOffset = icon.vOffset, + u = icon.regionWidth, + v = icon.regionHeight, + textureWidth = icon.textureWidth, + textureHeight = icon.textureHeight, + modifier = Modifier.sizeIn( + minWidth = icon.displayWidth, + minHeight = icon.displayHeight, + ), ) } + Text( + text = spec.title, + color = if (selected) theme.darkTextColor else theme.lightTextColor, + dropShadow = !selected + ) } } } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Button.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Button.kt index 1a8ec9093..2d0a0702d 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Button.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Button.kt @@ -12,12 +12,12 @@ import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.Renderer import net.kernelpanicsoft.archie.gui.modifiers.Modifier import net.kernelpanicsoft.archie.gui.modifiers.DebugModifier -import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.gui.modifiers.position.offset import net.kernelpanicsoft.archie.gui.nodes.UINode import net.kernelpanicsoft.archie.gui.theme.LocalTheme -import net.kernelpanicsoft.archie.gui.theme.SimpleThemeState import net.kernelpanicsoft.archie.gui.theme.ThemeVariants +import net.kernelpanicsoft.archie.gui.theme.contentPaddingModifier +import net.kernelpanicsoft.archie.gui.theme.intrinsicSizeModifier import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.client.gui.GuiGraphics @@ -26,7 +26,7 @@ import kotlin.time.Duration.Companion.milliseconds /** * A standard themed, clickable button. * - * Renders the themed [texture] state ([TextureStates.DEFAULT]/[TextureStates.HOVERED]/ + * Renders the themed [texture] state ([TextureStates.DEFAULT]/[TextureStates.FOCUSED]/ * [TextureStates.CLICKED]/[TextureStates.DISABLED]) behind [content], animating a 1px press * offset while held. For fully custom visuals, use [ButtonCore] directly instead. * @@ -54,7 +54,7 @@ fun Button( onClick, modifier, enabled - ) { isHovered, isPressed -> + ) { isHovered, isPressed, isFocused -> val pressOffset = animateInt( targetValue = if (isPressed) 1 else 0, spec = AnimationSpec(durationMillis = 90.milliseconds, easing = Easings.OutCubic), @@ -77,7 +77,7 @@ fun Button( ) = guiGraphics { val stateKey = WidgetState.resolve( composableTheme, variant, - WidgetState.clicked(isPressed), WidgetState.hovered(isHovered), + WidgetState.clicked(isPressed), WidgetState.focused(isHovered || isFocused), enabled = enabled, ) node.renderState = stateKey @@ -86,16 +86,10 @@ fun Button( drawThemeState(state, x, y, node.width, node.height) } }, - modifier = modifier.apply { - if (!composableTheme.isNineslice) { - with(composableTheme.states[TextureStates.DEFAULT] as SimpleThemeState) { - sizeIn( - minWidth = width, - minHeight = height - ) - } - } - }.offset(x = 0, y = pressOffset) + modifier = modifier + .then(composableTheme.intrinsicSizeModifier()) + .then(composableTheme.contentPaddingModifier()) + .offset(x = 0, y = pressOffset) ) } } @@ -104,16 +98,20 @@ fun Button( /** * A stateless clickable container composable. * - * `ButtonCore` manages hover and pressed state internally and exposes them to [content] - * via the lambda parameters. It handles cursor changes and the full pointer-event lifecycle, - * but applies no visual styling of its own — that is left entirely to [content]. + * `ButtonCore` manages hover, pressed and focus state internally and exposes them to + * [content] via the lambda parameters. It handles cursor changes, the full pointer-event + * lifecycle, and vanilla keyboard/controller focus navigation - Tab/Shift-Tab and arrow keys + * (via `Screen.children()`/`nextFocusPath`) can reach and activate it (Enter/Space) exactly + * like a plain `AbstractWidget`, including through controller-navigation mods such as + * Controlify. It applies no visual styling of its own - `isFocused` is exposed to [content] + * so callers can render their own focus indicator (see [Button]'s themed `focused` state). * * Use [ButtonCore] when you need custom button visuals. For a standard themed button, use * [Button] instead. * * ### Example * ```kotlin - * ButtonCore(onClick = { println("Clicked!") }) { isHovered, isPressed -> + * ButtonCore(onClick = { println("Clicked!") }) { isHovered, isPressed, isFocused -> * Box( * modifier = Modifier.background(if (isHovered) KColor.LIGHT_GRAY else KColor.GRAY) * .size(80, 20) @@ -123,23 +121,27 @@ fun Button( * } * ``` * - * @param onClick Invoked with the receiving [UINode] when the button is pressed. + * @param onClick Invoked with the receiving [UINode] when the button is pressed (by mouse, + * or by Enter/Space while vanilla-focused). * @param modifier Additional modifiers applied to the outer clickable container. - * @param enabled When `false`, pointer events are ignored and no cursor change occurs. - * @param content The button's visual content, receiving `isHovered` and `isPressed` booleans. + * @param enabled When `false`, pointer and activation-key events are ignored and no cursor + * change occurs. + * @param content The button's visual content, receiving `isHovered`, `isPressed` and + * `isFocused` booleans. */ @Composable fun ButtonCore( onClick: (UINode) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - content: @Composable (isHovered: Boolean, isPressed: Boolean) -> Unit, + content: @Composable (isHovered: Boolean, isPressed: Boolean, isFocused: Boolean) -> Unit, ) { Clickable( onClick = onClick, enabled = enabled, - modifier = Modifier.then(DebugModifier(strs = listOf("Enabled: $enabled"))).then(modifier), - ) { isHovered, isPressed -> - content(isHovered, isPressed) - } + modifier = Modifier + .then(DebugModifier(strs = listOf("Enabled: $enabled"))) + .then(modifier), + content = content, + ) } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Checkbox.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Checkbox.kt index 82d2a01bd..9e223ad7d 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Checkbox.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Checkbox.kt @@ -1,25 +1,28 @@ package net.kernelpanicsoft.archie.gui.composables.input import androidx.compose.runtime.* -import net.kernelpanicsoft.archie.gui.composables.theme.TextureStates +import net.kernelpanicsoft.archie.gui.animation.AnimationSpec +import net.kernelpanicsoft.archie.gui.animation.Easings +import net.kernelpanicsoft.archie.gui.animation.animatePulse import net.kernelpanicsoft.archie.gui.composables.theme.WidgetState +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState import net.kernelpanicsoft.archie.gui.layout.Alignment -import net.kernelpanicsoft.archie.gui.layout.Box import net.kernelpanicsoft.archie.gui.layout.BoxMeasurePolicy import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.Renderer import net.kernelpanicsoft.archie.gui.modifiers.Modifier -import net.kernelpanicsoft.archie.gui.modifiers.debug -import net.kernelpanicsoft.archie.gui.modifiers.input.PointerEventType -import net.kernelpanicsoft.archie.gui.modifiers.input.onPointerEvent -import net.kernelpanicsoft.archie.gui.modifiers.sizeIn +import net.kernelpanicsoft.archie.gui.modifiers.input.toggleable import net.kernelpanicsoft.archie.gui.nodes.UINode import net.kernelpanicsoft.archie.gui.theme.LocalTheme -import net.kernelpanicsoft.archie.gui.theme.SimpleThemeState import net.kernelpanicsoft.archie.gui.theme.ThemeVariants +import net.kernelpanicsoft.archie.gui.theme.intrinsicSizeModifier import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.client.gui.GuiGraphics +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds /** * A standard themed checkbox. @@ -30,6 +33,8 @@ import net.minecraft.client.gui.GuiGraphics * * @param checked The current checked state. * @param modifier Additional modifiers applied to the outer container. + * @param enabled When `false`, the disabled state is drawn, pointer/activation-key + * events are ignored, and this drops out of the vanilla focus graph entirely. * @param texture The themed texture key to look up via [LocalTheme]. * @param variant The theme variant of [texture] to use. See [ThemeVariants]. * @param onCheckedChange Called with the new checked value when the user clicks. @@ -38,26 +43,27 @@ import net.minecraft.client.gui.GuiGraphics fun Checkbox( checked: Boolean = false, modifier: Modifier = Modifier, + enabled: Boolean = true, texture: String = "checkbox", variant: String = ThemeVariants.DEFAULT, onCheckedChange: (Boolean) -> Unit, ) { val theme = LocalTheme.current val composableTheme = theme.getComposableTheme(texture) - val sizeModifier = if (!composableTheme.isNineslice) { - with(composableTheme.states[TextureStates.DEFAULT] as SimpleThemeState) { - Modifier.sizeIn( - minWidth = width, - minHeight = height - ) - } - } else Modifier + val sizeModifier = composableTheme.intrinsicSizeModifier() + // A brief overshoot-then-settle pop whenever `checked` flips, rather than the check mark + // snapping in/out instantly - purely cosmetic, so it's scaled around the node's own center + // instead of touching layout size. + val pop = animatePulse( + key = checked, + spec = AnimationSpec(durationMillis = 160.milliseconds, easing = Easings.OutBack), + ) CheckboxCore( - checked, - sizeModifier.then(modifier), - onCheckedChange - ) { isHovered -> + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + ) { checkboxModifier, isHovered, isFocused -> Layout( name = "Checkbox", measurePolicy = BoxMeasurePolicy(Alignment.Center), @@ -74,56 +80,70 @@ fun Checkbox( ) = guiGraphics { val stateKey = WidgetState.resolve( composableTheme, variant, - WidgetState.clicked(checked), WidgetState.hovered(isHovered), + WidgetState.clicked(checked), WidgetState.focused(isHovered || isFocused), + enabled = enabled, ) node.renderState = stateKey val state = composableTheme.getState(stateKey, variant) - drawThemeState(state, x, y, node.width, node.height) + val drawWidth = (node.width * pop).roundToInt() + val drawHeight = (node.height * pop).roundToInt() + drawThemeState( + state, + x + (node.width - drawWidth) / 2, + y + (node.height - drawHeight) / 2, + drawWidth, + drawHeight, + ) } }, - modifier = sizeModifier + modifier = checkboxModifier.then(sizeModifier).then(modifier) ) } } /** - * A stateless, unstyled toggle composable. + * A stateless, unstyled toggle composable, built on [Modifier.toggleable]. * - * `CheckboxCore` manages hover state internally and exposes it to [content]. All visual - * styling (textures, colours, checked indicator) is the responsibility of [content]. Use - * this as the base for custom or theme-driven checkbox implementations. + * `CheckboxCore` manages hover/focus state internally and hands [content] the [Modifier] it + * needs to apply to its own node to participate in pointer/focus input - it's a vanilla + * keyboard/controller focus-navigation stop that toggles on Enter/Space while focused, the + * same as a mouse click. All visual styling (textures, colours, checked indicator) is the + * responsibility of [content]. Use this as the base for custom or theme-driven checkbox + * implementations. * * ### Example * ```kotlin * var checked by remember { mutableStateOf(false) } - * CheckboxCore(checked = checked, onCheckedChange = { checked = it }) { isHovered -> - * Box(modifier = Modifier.size(16, 16).background(if (checked) KColor.GREEN else KColor.GRAY)) + * CheckboxCore(checked = checked, onCheckedChange = { checked = it }) { modifier, isHovered, isFocused -> + * Box(modifier = modifier.size(16, 16).background(if (checked) KColor.GREEN else KColor.GRAY)) * } * ``` * * @param checked The current checked state. - * @param modifier Additional modifiers applied to the outer [Box]. + * @param enabled When `false`, pointer and activation-key events are ignored and this + * drops out of the vanilla focus graph entirely. * @param onCheckedChange Called with the new checked value when the user clicks. - * @param content The visual content; receives `isHovered` for styling. + * @param content The visual content; receives the [Modifier] to apply to its own node, + * plus `isHovered`/`isFocused` for styling. */ @Composable fun CheckboxCore( checked: Boolean = false, - modifier: Modifier = Modifier, + enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit, - content: @Composable (isHovered: Boolean) -> Unit, + content: @Composable (modifier: Modifier, isHovered: Boolean, isFocused: Boolean) -> Unit, ) { - var hovered by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isFocused by interactionSource.collectIsFocusedAsState() - Box( - modifier = Modifier - .debug("Hovered: $hovered") - .onPointerEvent(PointerEventType.ENTER) { _, e -> hovered = true; e.consume() } - .onPointerEvent(PointerEventType.EXIT) { _, e -> hovered = false; e.consume() } - .onPointerEvent(PointerEventType.PRESS) { _, e -> onCheckedChange(!checked); e.consume() } - .then(modifier), - ) { - content(hovered) - } + val toggleableModifier = Modifier.toggleable( + value = checked, + enabled = enabled, + interactionSource = interactionSource, + onValueChange = onCheckedChange, + ) + + content(toggleableModifier, isHovered, isFocused) } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Clickable.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Clickable.kt index cbd76eb7e..af3b5d5ab 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Clickable.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Clickable.kt @@ -3,14 +3,18 @@ package net.kernelpanicsoft.archie.gui.composables.input import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsPressedAsState import net.kernelpanicsoft.archie.gui.layout.Alignment import net.kernelpanicsoft.archie.gui.layout.Box import net.kernelpanicsoft.archie.gui.modifiers.Modifier -import net.kernelpanicsoft.archie.gui.modifiers.input.PointerEventType -import net.kernelpanicsoft.archie.gui.modifiers.input.onPointerEvent +import net.kernelpanicsoft.archie.gui.modifiers.input.focusable +import net.kernelpanicsoft.archie.gui.modifiers.input.hoverable +import net.kernelpanicsoft.archie.gui.modifiers.input.onKeyEvent +import net.kernelpanicsoft.archie.gui.modifiers.input.pressable import net.kernelpanicsoft.archie.gui.nodes.UINode import net.kernelpanicsoft.archie.util.minecraftClient import net.minecraft.client.Minecraft @@ -20,6 +24,9 @@ private object CursorCache { val handCursor: Long by lazy { GLFW.glfwCreateStandardCursor(GLFW.GLFW_HAND_CURSOR) } } +/** GLFW key codes that activate a focused clickable, mirroring vanilla `AbstractWidget` activation. */ +private val CLICK_ACTIVATION_KEYS = intArrayOf(GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER, GLFW.GLFW_KEY_SPACE) + private fun setHandCursor(enabled: Boolean) { // GLFW calls must happen on the render thread; DisposableEffect callbacks run on the // recomposition dispatcher, so hop over via Minecraft's thread-safe task queue. @@ -32,15 +39,22 @@ private fun setHandCursor(enabled: Boolean) { /** * Low-level unstyled clickable container used by higher-level inputs like [ButtonCore]. * - * Tracks hover/press state and fires [onClick] on press (not release), showing the system - * hand cursor on hover when [showHandCursor] is `true`. Applies no visual styling itself - - * that is entirely up to [content]. + * Tracks hover/press state via [hoverable]/[pressable] and fires [onClick] on press (not + * release), showing the system hand cursor on hover when [showHandCursor] is `true`. Also + * registers as a vanilla keyboard/controller focus-navigation stop (see [focusable]) and fires + * [onClick] on Enter/Space while focused - the same activation model Compose Foundation's own + * `Modifier.clickable` bakes in, rather than treating focus as a separate concern from click. + * Applies no visual styling itself - that is entirely up to [content]. * - * @param onClick Invoked with the receiving [UINode] on press. - * @param modifier Additional modifiers applied to the outer [Box]. - * @param enabled When `false`, pointer events are ignored and no cursor change occurs. - * @param showHandCursor Whether to switch to the hand cursor while hovered. - * @param content The visual content; receives `isHovered`/`isPressed` for styling. + * @param onClick Invoked with the receiving [UINode] on press, or on Enter/Space + * while vanilla-focused. + * @param modifier Additional modifiers applied to the outer [Box]. + * @param enabled When `false`, pointer and activation-key events are ignored, no + * cursor change occurs, and this drops out of the vanilla focus graph entirely. + * @param showHandCursor Whether to switch to the hand cursor while hovered. + * @param interactionSource Backs `isHovered`/`isPressed`/`isFocused`, collectible independently + * via `net.kernelpanicsoft.archie.gui.interaction.collectIsXAsState` too. + * @param content The visual content; receives `isHovered`/`isPressed`/`isFocused` for styling. */ @Composable fun Clickable( @@ -48,13 +62,15 @@ fun Clickable( modifier: Modifier = Modifier, enabled: Boolean = true, showHandCursor: Boolean = true, - content: @Composable (isHovered: Boolean, isPressed: Boolean) -> Unit, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable (isHovered: Boolean, isPressed: Boolean, isFocused: Boolean) -> Unit, ) { - var hovered by remember { mutableStateOf(false) } - var pressed by remember { mutableStateOf(false) } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val isFocused by interactionSource.collectIsFocusedAsState() - DisposableEffect(enabled, hovered, showHandCursor) { - if ((!enabled || !hovered) && showHandCursor) setHandCursor(false) + DisposableEffect(enabled, isHovered, showHandCursor) { + if (showHandCursor) setHandCursor(enabled && isHovered) onDispose { if (showHandCursor) setHandCursor(false) } @@ -62,31 +78,18 @@ fun Clickable( Box( modifier = Modifier - .onPointerEvent(PointerEventType.ENTER) { _, e -> - if (!enabled) return@onPointerEvent - hovered = true - if (showHandCursor) setHandCursor(true) - e.consume() - } - .onPointerEvent(PointerEventType.EXIT) { _, e -> - hovered = false - pressed = false - if (showHandCursor) setHandCursor(false) - if (enabled) e.consume() - } - .onPointerEvent(PointerEventType.PRESS) { node, e -> - if (!enabled) return@onPointerEvent - pressed = true - onClick(node) - e.consume(true) - } - .onPointerEvent(PointerEventType.GLOBAL_RELEASE) { _, _ -> - pressed = false + .focusable(enabled = enabled, interactionSource = interactionSource) + .onKeyEvent { node, e -> + if (enabled && isFocused && e.keyCode in CLICK_ACTIVATION_KEYS) { + onClick(node) + e.consume(bypassSuperCall = true) + } } + .hoverable(interactionSource, enabled = enabled) + .pressable(interactionSource, enabled = enabled, onPress = onClick) .then(modifier), contentAlignment = Alignment.Center, ) { - content(hovered, pressed) + content(isHovered, isPressed, isFocused) } } - diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Radio.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Radio.kt index 2ef5cd187..17206c819 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Radio.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Radio.kt @@ -1,10 +1,17 @@ package net.kernelpanicsoft.archie.gui.composables.input import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import net.kernelpanicsoft.archie.gui.animation.AnimationSpec +import net.kernelpanicsoft.archie.gui.animation.Easings +import net.kernelpanicsoft.archie.gui.animation.animatePulse import net.kernelpanicsoft.archie.gui.composables.basic.Text import net.kernelpanicsoft.archie.gui.composables.theme.TextureStates import net.kernelpanicsoft.archie.gui.composables.theme.WidgetState +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState import net.kernelpanicsoft.archie.gui.layout.Alignment import net.kernelpanicsoft.archie.gui.layout.Arrangement import net.kernelpanicsoft.archie.gui.layout.BoxMeasurePolicy @@ -13,44 +20,51 @@ import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.Renderer import net.kernelpanicsoft.archie.gui.layout.Row import net.kernelpanicsoft.archie.gui.modifiers.Modifier -import net.kernelpanicsoft.archie.gui.modifiers.sizeIn +import net.kernelpanicsoft.archie.gui.modifiers.input.selectable import net.kernelpanicsoft.archie.gui.nodes.UINode import net.kernelpanicsoft.archie.gui.theme.LocalTheme -import net.kernelpanicsoft.archie.gui.theme.SimpleThemeState import net.kernelpanicsoft.archie.gui.theme.ThemeVariants +import net.kernelpanicsoft.archie.gui.theme.intrinsicSizeModifier import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.client.gui.GuiGraphics import net.minecraft.network.chat.Component +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds /** - * Low-level unstyled radio-button behavior, built on [Clickable]. + * Low-level unstyled radio-button behavior, built on [Modifier.selectable]. * * Calls [onSelect] on press only when not already [selected] (clicking an already-selected - * radio option is a no-op, matching standard radio-group semantics). Applies no visuals - - * that is up to [content]. + * radio option is a no-op, matching standard radio-group semantics) - the same on Enter/Space + * while this is a vanilla keyboard/controller focus-navigation stop. Applies no visuals - that + * is up to [content]. * * @param selected Whether this option is currently selected. * @param onSelect Invoked when this (unselected) option is clicked. - * @param modifier Additional modifiers applied to the outer clickable container. * @param enabled When `false`, pointer events are ignored. - * @param content The visual content; receives hover/press state and [selected]. + * @param content The visual content; receives the [Modifier] to apply to its own node, plus + * hover/focus state and [selected]. */ @Composable fun RadioButtonCore( selected: Boolean, onSelect: () -> Unit, - modifier: Modifier = Modifier, enabled: Boolean = true, - content: @Composable (isHovered: Boolean, isPressed: Boolean, selected: Boolean) -> Unit, + content: @Composable (modifier: Modifier, isHovered: Boolean, isFocused: Boolean, selected: Boolean) -> Unit, ) { - Clickable( - onClick = { if (!selected) onSelect() }, + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isFocused by interactionSource.collectIsFocusedAsState() + + val selectableModifier = Modifier.selectable( + selected = selected, enabled = enabled, - modifier = modifier, - ) { hovered, pressed -> - content(hovered, pressed, selected) - } + interactionSource = interactionSource, + onClick = { if (!selected) onSelect() }, + ) + + content(selectableModifier, isHovered, isFocused, selected) } /** @@ -78,22 +92,24 @@ fun RadioButton( val theme = LocalTheme.current val composableTheme = theme.getComposableTheme(texture) val measurePolicy = remember { BoxMeasurePolicy(Alignment.Center) } - val sizeModifier = if (!composableTheme.isNineslice) { - with(composableTheme.states[TextureStates.DEFAULT] as SimpleThemeState) { - Modifier.sizeIn(minWidth = width, minHeight = height) - } - } else Modifier + val sizeModifier = composableTheme.intrinsicSizeModifier() + // A brief overshoot-then-settle pop whenever `selected` flips, rather than the dot + // snapping in/out instantly - purely cosmetic, so it's scaled around the node's own + // center instead of touching layout size. + val pop = animatePulse( + key = selected, + spec = AnimationSpec(durationMillis = 160.milliseconds, easing = Easings.OutBack), + ) RadioButtonCore( selected = selected, onSelect = onSelect, enabled = enabled, - modifier = sizeModifier.then(modifier), - ) { hovered, _, currentSelected -> + ) { radioModifier, hovered, focused, currentSelected -> Layout( name = "RadioButton", measurePolicy = measurePolicy, - modifier = sizeModifier, + modifier = radioModifier.then(sizeModifier).then(modifier), renderer = object : Renderer { override fun render( node: UINode, @@ -106,13 +122,21 @@ fun RadioButton( ) = guiGraphics { val stateKey = WidgetState.resolve( composableTheme, variant, - WidgetState.clicked(currentSelected), WidgetState.hovered(hovered), + WidgetState.clicked(currentSelected), WidgetState.focused(hovered || focused), enabled = enabled, ) node.renderState = stateKey val state = composableTheme.getState(stateKey, variant) - drawThemeState(state, x, y, node.width, node.height) + val drawWidth = (node.width * pop).roundToInt() + val drawHeight = (node.height * pop).roundToInt() + drawThemeState( + state, + x + (node.width - drawWidth) / 2, + y + (node.height - drawHeight) / 2, + drawWidth, + drawHeight, + ) } }, ) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Slider.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Slider.kt index 149d01735..eaaafc221 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Slider.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Slider.kt @@ -5,13 +5,23 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import net.kernelpanicsoft.archie.gui.animation.AnimationSpec +import net.kernelpanicsoft.archie.gui.animation.animateFloat +import net.kernelpanicsoft.archie.gui.interaction.DragInteraction +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState import net.kernelpanicsoft.archie.gui.layout.Alignment import net.kernelpanicsoft.archie.gui.layout.BoxMeasurePolicy import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.Renderer +import net.kernelpanicsoft.archie.gui.layout.Size import net.kernelpanicsoft.archie.gui.modifiers.Modifier import net.kernelpanicsoft.archie.gui.modifiers.input.PointerEventType +import net.kernelpanicsoft.archie.gui.modifiers.input.focusable +import net.kernelpanicsoft.archie.gui.modifiers.input.hoverable import net.kernelpanicsoft.archie.gui.modifiers.input.onDrag +import net.kernelpanicsoft.archie.gui.modifiers.input.onKeyEvent import net.kernelpanicsoft.archie.gui.modifiers.input.onPointerEvent import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.gui.nodes.UINode @@ -22,14 +32,21 @@ import net.kernelpanicsoft.archie.gui.theme.ThemeVariants import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.client.gui.GuiGraphics +import org.lwjgl.glfw.GLFW import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +/** Fallback overall size when the "slider" theme doesn't declare its own [ComposableTheme.minSize]. */ private const val SLIDER_MIN_WIDTH = 96 private const val SLIDER_MIN_HEIGHT = 20 +/** Fallback thumb size when the "slider_handle" theme doesn't declare its own [ComposableTheme.minSize]. */ private const val SLIDER_THUMB_WIDTH = 8 private const val SLIDER_THUMB_HEIGHT = 20 private const val SLIDER_TRACK_HEIGHT = 2 +/** Fraction of the full 0f..1f range one arrow-key press moves, for a continuous (steps == 0) slider. */ +private const val KEYBOARD_STEP_FALLBACK = 0.05f + /** Clamps a slider value into the normalized `0f..1f` range. */ internal fun normalizeSliderValue(value: Float): Float = value.coerceIn(0f, 1f) @@ -48,20 +65,26 @@ internal fun resolveSliderThumbX(rawThumbX: Int, sliderX: Int, sliderWidth: Int, return rawThumbX.coerceIn(minThumbX, maxThumbX) } -private fun resolveSliderStateName(theme: ComposableTheme, variant: String, enabled: Boolean, hovered: Boolean, dragging: Boolean): String = - WidgetState.resolve(theme, variant, WidgetState.clicked(dragging), WidgetState.hovered(hovered), enabled = enabled) +private fun resolveSliderStateName(theme: ComposableTheme, variant: String, enabled: Boolean, hovered: Boolean, dragging: Boolean, focused: Boolean): String = + WidgetState.resolve(theme, variant, WidgetState.clicked(dragging), WidgetState.focused(hovered || focused), enabled = enabled) /** - * Low-level unstyled slider behavior: drag/click-to-position and hover/drag state tracking, + * Low-level unstyled slider behavior: drag/click-to-position, hover/drag/focus state tracking, * with no visuals of its own. * + * A vanilla keyboard/controller focus-navigation stop - unlike the simple toggle inputs + * ([Checkbox], [Switch], [RadioButton]), a slider has no single "activate" action, so instead + * of [Clickable]'s Enter/Space it responds to Left/Right arrow keys while focused, nudging the + * value by one [steps] increment (or [KEYBOARD_STEP_FALLBACK] when continuous). + * * @param value The current value, normalized/snapped via [snapSliderValue]. - * @param onValueChange Called with the new normalized value on every drag/click update. + * @param onValueChange Called with the new normalized value on every drag/click/arrow-key update. * @param modifier Additional modifiers applied to the outer container. - * @param enabled When `false`, pointer events are ignored. + * @param enabled When `false`, pointer and arrow-key events are ignored and this + * drops out of the vanilla focus graph entirely. * @param steps Number of discrete increments to snap to; `0` means continuous. - * @param onValueChangeFinished Called once when a drag interaction ends (on release). - * @param content The visual content; receives hover/drag state and the + * @param onValueChangeFinished Called once when a drag or arrow-key interaction ends. + * @param content The visual content; receives hover/drag/focus state and the * normalized, snapped value to render. */ @Composable @@ -72,12 +95,17 @@ fun SliderCore( enabled: Boolean = true, steps: Int = 0, onValueChangeFinished: () -> Unit = {}, - content: @Composable (isHovered: Boolean, isDragging: Boolean, normalizedValue: Float) -> Unit, + content: @Composable (isHovered: Boolean, isDragging: Boolean, isFocused: Boolean, normalizedValue: Float) -> Unit, ) { val normalizedValue = snapSliderValue(value, steps) - var hovered by remember { mutableStateOf(false) } + // `dragging` stays a plain local var (rather than collectIsDraggedAsState()) since onDrag + // below needs to synchronously tell "a drag that started on this slider" apart from a + // stray onDrag call, not just report state for rendering. var dragging by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val isFocused by interactionSource.collectIsFocusedAsState() fun updateFromPointer(node: UINode, mouseX: Double) { val localX = (mouseX - node.x).toFloat() @@ -89,19 +117,27 @@ fun SliderCore( name = "SliderCore", measurePolicy = remember { BoxMeasurePolicy(Alignment.CenterStart) }, modifier = Modifier - .onPointerEvent(PointerEventType.ENTER) { _, event -> - if (!enabled) return@onPointerEvent - hovered = true - event.consume() - } - .onPointerEvent(PointerEventType.EXIT) { _, event -> - hovered = false - dragging = false - if (enabled) event.consume() + .focusable(enabled = enabled, interactionSource = interactionSource) + .onKeyEvent { _, event -> + if (!enabled || !isFocused) return@onKeyEvent + val step = if (steps > 0) 1f / steps else KEYBOARD_STEP_FALLBACK + val delta = when (event.keyCode) { + GLFW.GLFW_KEY_LEFT -> -step + GLFW.GLFW_KEY_RIGHT -> step + else -> return@onKeyEvent + } + onValueChange(snapSliderValue(normalizedValue + delta, steps)) + onValueChangeFinished() + event.consume(bypassSuperCall = true) } + .hoverable(interactionSource, enabled = enabled) + // Not Modifier.draggable(): that reports only a delta per movement, but pressing + // anywhere on the track needs to jump straight to that absolute position - the same + // reason Compose Foundation's own Slider doesn't build on plain draggable either. .onPointerEvent(PointerEventType.PRESS) { node, event -> if (!enabled) return@onPointerEvent dragging = true + interactionSource.tryEmit(DragInteraction.Start) updateFromPointer(node, event.mouseX) event.consume(true) } @@ -113,17 +149,18 @@ fun SliderCore( .onPointerEvent(PointerEventType.GLOBAL_RELEASE) { _, _ -> if (!enabled || !dragging) return@onPointerEvent dragging = false + interactionSource.tryEmit(DragInteraction.Stop) onValueChangeFinished() } .then(modifier), ) { - content(hovered, dragging, normalizedValue) + content(hovered, dragging, isFocused, normalizedValue) } } /** - * A standard themed horizontal slider, drawing a "slider" track and "slider_handle" thumb - * from the current theme, plus a solid-color fill up to the thumb. + * A standard themed horizontal slider, drawing a "slider" track, "slider_handle" thumb, and + * "slider_fill" progress fill up to the thumb, all from the current theme. * * @param value The current value, normalized/snapped via [snapSliderValue]. * @param onValueChange Called with the new normalized value on every drag/click update. @@ -147,7 +184,20 @@ fun Slider( val theme = LocalTheme.current val trackTheme = theme.getComposableTheme("slider") val thumbTheme = theme.getComposableTheme("slider_handle") - val sizeModifier = Modifier.sizeIn(minWidth = SLIDER_MIN_WIDTH, minHeight = SLIDER_MIN_HEIGHT) + val fillTheme = theme.getComposableTheme("slider_fill") + // A theme's handle art isn't always native to SLIDER_THUMB_WIDTH/HEIGHT's proportions + // (tuned for the default look, a tall thin pill) - min_size on the "slider"/"slider_handle" + // theme entries lets a theme declare its own real dimensions instead of getting + // force-stretched into ones it wasn't designed for (a compact/roughly-square handle nine-sliced + // into a tall thin pill looks visibly distorted). + val sliderSize = trackTheme.minSize ?: Size(SLIDER_MIN_WIDTH, SLIDER_MIN_HEIGHT) + val thumbSize = thumbTheme.minSize ?: Size(SLIDER_THUMB_WIDTH, SLIDER_THUMB_HEIGHT) + val sizeModifier = Modifier.sizeIn(minWidth = sliderSize.width, minHeight = sliderSize.height) + // Java's fill is a thin accent line (SLIDER_TRACK_HEIGHT) drawn over a much taller track, + // but some themes draw the fill as a full-thickness bicolor bar (filled portion one shade, + // unfilled another, both the same thickness as the track itself) - "slider_fill"'s own + // min_size height overrides how tall the fill renders when a theme wants that look. + val fillHeight = fillTheme.minSize?.height ?: SLIDER_TRACK_HEIGHT SliderCore( value = value, onValueChange = onValueChange, @@ -155,7 +205,13 @@ fun Slider( steps = steps, onValueChangeFinished = onValueChangeFinished, modifier = sizeModifier.then(modifier), - ) { hovered, dragging, normalizedValue -> + ) { hovered, dragging, focused, normalizedValue -> + // Grows the thumb slightly on hover/drag instead of it staying a fixed size regardless + // of interaction, matching common slider affordance conventions. + val thumbScale = animateFloat( + targetValue = if (enabled && (hovered || dragging)) 1.25f else 1f, + spec = AnimationSpec(durationMillis = 120.milliseconds), + ) Layout( name = "Slider", measurePolicy = measurePolicy, @@ -170,29 +226,44 @@ fun Slider( mouseY: Int, partialTick: Float, ) = guiGraphics { - val trackY = y + (node.height - SLIDER_TRACK_HEIGHT) / 2 - val trackStart = x + (SLIDER_THUMB_WIDTH / 2) - val trackEnd = x + node.width - (SLIDER_THUMB_WIDTH / 2) + val trackY = y + (node.height - fillHeight) / 2 + // Spans the track's actual full width (0%..100% -> fully unfilled..fully + // filled, touching both edges) - the thumb's own half-width inset is handled + // separately by resolveSliderThumbX's clamp below, so insetting trackStart/ + // trackEnd here too would double up, leaving a permanent gap the fill could + // never close even at 100%. + val trackStart = x + val trackEnd = x + node.width val availableTrack = (trackEnd - trackStart).coerceAtLeast(1) val fillEnd = trackStart + (availableTrack * normalizedValue).roundToInt() val thumbX = resolveSliderThumbX( - rawThumbX = fillEnd - (SLIDER_THUMB_WIDTH / 2), + rawThumbX = fillEnd - (thumbSize.width / 2), sliderX = x, sliderWidth = node.width, - thumbWidth = SLIDER_THUMB_WIDTH, + thumbWidth = thumbSize.width, ) - val thumbY = y + (node.height - SLIDER_THUMB_HEIGHT) / 2 + val thumbY = y + (node.height - thumbSize.height) / 2 - val stateName = resolveSliderStateName(trackTheme, variant, enabled, hovered, dragging) + val stateName = resolveSliderStateName(trackTheme, variant, enabled, hovered, dragging, focused) node.renderState = stateName val trackState = trackTheme.getState(stateName, variant) val thumbState = thumbTheme.getState(stateName, variant) - - val fillColor = if (enabled) 0xFF6BA8FF.toInt() else 0xFF5A5A5A.toInt() + val fillState = fillTheme.getState(WidgetState.resolve(fillTheme, variant, enabled = enabled), variant) drawThemeState(trackState, x, y, node.width, node.height) - fill(trackStart, trackY, fillEnd, trackY + SLIDER_TRACK_HEIGHT, fillColor) - drawThemeState(thumbState, thumbX, thumbY, SLIDER_THUMB_WIDTH, SLIDER_THUMB_HEIGHT) + if (fillEnd > trackStart) { + drawThemeState(fillState, trackStart, trackY, fillEnd - trackStart, fillHeight) + } + + val drawThumbWidth = (thumbSize.width * thumbScale).roundToInt() + val drawThumbHeight = (thumbSize.height * thumbScale).roundToInt() + drawThemeState( + thumbState, + thumbX + (thumbSize.width - drawThumbWidth) / 2, + thumbY + (thumbSize.height - drawThumbHeight) / 2, + drawThumbWidth, + drawThumbHeight, + ) } }, ) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Switch.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Switch.kt index 1ee370a11..c9084d2d9 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Switch.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/Switch.kt @@ -1,19 +1,26 @@ package net.kernelpanicsoft.archie.gui.composables.input import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import net.kernelpanicsoft.archie.gui.animation.AnimationSpec import net.kernelpanicsoft.archie.gui.animation.Easings import net.kernelpanicsoft.archie.gui.animation.animateInt import net.kernelpanicsoft.archie.gui.composables.theme.TextureStates import net.kernelpanicsoft.archie.gui.composables.theme.WidgetState +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState import net.kernelpanicsoft.archie.gui.layout.BoxMeasurePolicy import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.Renderer import net.kernelpanicsoft.archie.gui.layout.Alignment +import net.kernelpanicsoft.archie.gui.layout.Size import net.kernelpanicsoft.archie.gui.modifiers.Modifier +import net.kernelpanicsoft.archie.gui.modifiers.input.toggleable import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.gui.nodes.UINode +import net.kernelpanicsoft.archie.gui.theme.ComposableTheme import net.kernelpanicsoft.archie.gui.theme.LocalTheme import net.kernelpanicsoft.archie.gui.theme.ThemeVariants import net.kernelpanicsoft.archie.gui.util.extension.drawThemeState @@ -21,36 +28,45 @@ import net.kernelpanicsoft.archie.gui.util.extension.invoke import net.minecraft.client.gui.GuiGraphics import kotlin.time.Duration.Companion.milliseconds +/** Fallback track size when the "switch_track" theme doesn't declare its own [ComposableTheme.minSize]. */ private const val SWITCH_MIN_WIDTH = 34 private const val SWITCH_MIN_HEIGHT = 18 +/** Fallback gap between the thumb and the track's edge, when "switch_track" doesn't declare its own [ComposableTheme.contentPadding]. */ private const val SWITCH_PADDING = 2 +/** Fallback (square) thumb size when the "switch_thumb" theme doesn't declare its own [ComposableTheme.minSize]. */ private const val SWITCH_THUMB_SIZE = 14 -/** Clamps a raw thumb x-offset so the thumb stays within the track, respecting [SWITCH_PADDING]. */ -internal fun resolveSwitchThumbOffset(thumbOffset: Int, trackWidth: Int): Int { - val minOffset = SWITCH_PADDING - val maxOffset = (trackWidth - SWITCH_THUMB_SIZE - SWITCH_PADDING).coerceAtLeast(minOffset) +/** Clamps a raw thumb x-offset so the [thumbWidth]-wide thumb stays within the track, respecting [padding]. */ +internal fun resolveSwitchThumbOffset(thumbOffset: Int, trackWidth: Int, thumbWidth: Int = SWITCH_THUMB_SIZE, padding: Int = SWITCH_PADDING): Int { + val minOffset = padding + val maxOffset = (trackWidth - thumbWidth - padding).coerceAtLeast(minOffset) return thumbOffset.coerceIn(minOffset, maxOffset) } /** - * Low-level switch primitive exposing hover/press state and checked state to custom visuals. + * Low-level switch primitive exposing hover/focus state and checked state to custom visuals. + * Built on [Modifier.toggleable], so it's a vanilla keyboard/controller focus-navigation stop + * that toggles on Enter/Space while focused, the same as a mouse click. */ @Composable fun SwitchCore( checked: Boolean, onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, enabled: Boolean = true, - content: @Composable (isHovered: Boolean, isPressed: Boolean, checked: Boolean) -> Unit, + content: @Composable (modifier: Modifier, isHovered: Boolean, isFocused: Boolean, checked: Boolean) -> Unit, ) { - Clickable( - onClick = { onCheckedChange(!checked) }, + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isFocused by interactionSource.collectIsFocusedAsState() + + val toggleableModifier = Modifier.toggleable( + value = checked, enabled = enabled, - modifier = modifier, - ) { hovered, pressed -> - content(hovered, pressed, checked) - } + interactionSource = interactionSource, + onValueChange = onCheckedChange, + ) + + content(toggleableModifier, isHovered, isFocused, checked) } /** @@ -82,9 +98,19 @@ fun Switch( val trackTheme = theme.getComposableTheme(trackTexture) val thumbTheme = theme.getComposableTheme(thumbTexture) val measurePolicy = remember { BoxMeasurePolicy(Alignment.CenterStart) } - val sizeModifier = Modifier.sizeIn(minWidth = SWITCH_MIN_WIDTH, minHeight = SWITCH_MIN_HEIGHT) + // A theme's track/thumb art isn't always native to SWITCH_MIN_WIDTH/HEIGHT and + // SWITCH_THUMB_SIZE's proportions (tuned for the default look) - min_size on the + // "switch_track"/"switch_thumb" theme entries lets a theme declare its own real dimensions + // instead of getting force-stretched into ones it wasn't designed for. Likewise, + // content_padding's horizontal component overrides how far the thumb sits from the track's + // edge - some art (e.g. a track drawn flush to its own bounds) wants the thumb sitting + // right in the corner instead of inset by the default gap. + val trackSize = trackTheme.minSize ?: Size(SWITCH_MIN_WIDTH, SWITCH_MIN_HEIGHT) + val thumbSize = thumbTheme.minSize ?: Size(SWITCH_THUMB_SIZE, SWITCH_THUMB_SIZE) + val padding = trackTheme.contentPadding?.horizontal ?: SWITCH_PADDING + val sizeModifier = Modifier.sizeIn(minWidth = trackSize.width, minHeight = trackSize.height) val thumbOffset = animateInt( - targetValue = if (checked) SWITCH_MIN_WIDTH - SWITCH_THUMB_SIZE - SWITCH_PADDING else SWITCH_PADDING, + targetValue = if (checked) trackSize.width - thumbSize.width - padding else padding, spec = AnimationSpec(durationMillis = 140.milliseconds, easing = Easings.OutCubic), ) @@ -92,12 +118,11 @@ fun Switch( checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, - modifier = sizeModifier.then(modifier), - ) { hovered, _, currentChecked -> + ) { switchModifier, hovered, focused, currentChecked -> Layout( name = "Switch", measurePolicy = measurePolicy, - modifier = sizeModifier, + modifier = switchModifier.then(sizeModifier).then(modifier), renderer = object : Renderer { override fun render( node: UINode, @@ -110,7 +135,7 @@ fun Switch( ) = guiGraphics { val trackStateKey = WidgetState.resolve( trackTheme, variant, - WidgetState.clicked(currentChecked), WidgetState.hovered(hovered), + WidgetState.clicked(currentChecked), WidgetState.focused(hovered || focused), enabled = enabled, ) node.renderState = trackStateKey @@ -122,9 +147,9 @@ fun Switch( drawThemeState(trackState, x, y, node.width, node.height) - val thumbX = x + resolveSwitchThumbOffset(thumbOffset, node.width) - val thumbY = y + ((node.height - SWITCH_THUMB_SIZE) / 2) - drawThemeState(thumbState, thumbX, thumbY, SWITCH_THUMB_SIZE, SWITCH_THUMB_SIZE) + val thumbX = x + resolveSwitchThumbOffset(thumbOffset, node.width, thumbSize.width, padding) + val thumbY = y + ((node.height - thumbSize.height) / 2) + drawThemeState(thumbState, thumbX, thumbY, thumbSize.width, thumbSize.height) } }, ) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/textfield/TextFieldCore.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/textfield/TextFieldCore.kt index 93e3138fc..d3714cf74 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/textfield/TextFieldCore.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/input/textfield/TextFieldCore.kt @@ -2,6 +2,10 @@ package net.kernelpanicsoft.archie.gui.composables.input.textfield import androidx.compose.runtime.* import kotlinx.coroutines.delay +import net.kernelpanicsoft.archie.gui.LocalVanillaScreen +import net.kernelpanicsoft.archie.gui.focus.LayoutNodeFocusAdapter +import net.kernelpanicsoft.archie.gui.focus.LocalBringIntoViewParent +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource import net.kernelpanicsoft.archie.gui.layout.Layout import net.kernelpanicsoft.archie.gui.layout.LayoutNode import net.kernelpanicsoft.archie.gui.layout.MeasureResult @@ -32,8 +36,15 @@ class TextFieldState { var displayPos by mutableStateOf(0) /** Vertical scroll offset for multi-line fields, in pixels. */ var scrollY by mutableStateOf(0.0) + + // Backs isFocused as an explicit MutableState (rather than `by mutableStateOf`) so + // TextFieldCore can share this exact state with FocusableModifier - vanilla Tab/Shift-Tab + // navigation setting this text field's vanilla focus then reads back as isFocused too, + // through the same underlying state, no separate sync step needed. + internal val focusedState = mutableStateOf(false) /** Whether the field currently holds input focus. */ - var isFocused by mutableStateOf(false) + var isFocused: Boolean by focusedState + /** Whether the blinking cursor is currently visible. */ var showCursor by mutableStateOf(false) internal var lastBlink by mutableStateOf(0L) @@ -87,10 +98,18 @@ fun TextFieldCore( content: @Composable (state: TextFieldState) -> Unit, ) { val state = rememberTextFieldState() + val interactionSource = remember { MutableInteractionSource() } + val vanillaScreen = LocalVanillaScreen.current + val bringIntoViewParent = LocalBringIntoViewParent.current - // Cursor blink coroutine + // Cursor blink coroutine. Resets lastBlink/showCursor itself on (re)entering the focused + // branch rather than relying on onFocusChange's side effect for that, since vanilla Tab + // navigation focuses this field by writing state.focusedState directly (see the focusable() + // modifier below), not through onFocusChange. LaunchedEffect(state.isFocused) { if (state.isFocused) { + state.lastBlink = System.currentTimeMillis() + state.showCursor = true while (true) { val t = System.currentTimeMillis() if (t - state.lastBlink > CURSOR_BLINK_INTERVAL_MS) { state.showCursor = !state.showCursor; state.lastBlink = t } @@ -140,9 +159,15 @@ fun TextFieldCore( } }, modifier = modifier + // Bridges this field's existing pointer-driven focus into vanilla's own + // Tab/Shift-Tab focus graph, sharing state.focusedState directly (not the public + // Modifier.focusable(), which owns its own private state) - Tab can now reach a + // text field, and losing vanilla focus (e.g. a modal opening over it, per + // ComposeScreen/ComposeContainerScreen's own focus reset) blurs it the same way. + .let { if (enabled) it.then(FocusableModifier(state.focusedState, interactionSource, bringIntoViewParent)) else it } .onKeyEvent { _, event -> if (!enabled || !state.isFocused) return@onKeyEvent - if (event.keyCode == 256) { state.onFocusChange(false); event.consume(true); return@onKeyEvent } + if (event.keyCode == 256) { vanillaScreen.clearFocus(); event.consume(true); return@onKeyEvent } var handled = true val result = when { Screen.isSelectAll(event.keyCode) -> value.copy(selection = TextRange(0, value.text.length)) @@ -165,7 +190,7 @@ fun TextFieldCore( } .onPointerEvent(PointerEventType.PRESS) { node, event -> if (state.isFocused && !node.isBounded(event.mouseX.toInt(), event.mouseY.toInt())) - state.onFocusChange(false) + vanillaScreen.clearFocus() } .onPointerEvent(PointerEventType.PRESS) { node, event -> val (nX, _) = node.absoluteCoords @@ -173,7 +198,7 @@ fun TextFieldCore( if (!singleLine && event.mouseX >= scrollBarX && event.mouseX < nX + state.layoutInfo.width) { state.isDraggingScrollbar = true } else { - state.onFocusChange(true) + vanillaScreen.setFocused(LayoutNodeFocusAdapter(node)) val lX = event.mouseX - node.absoluteCoords.x - BORDER_PADDING val lY = event.mouseY - node.absoluteCoords.y - BORDER_PADDING val cur = findCursorPos(font, value.text, lX, lY, state, singleLine) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/ConfirmDialog.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/ConfirmDialog.kt deleted file mode 100644 index d942575c1..000000000 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/ConfirmDialog.kt +++ /dev/null @@ -1,108 +0,0 @@ -package net.kernelpanicsoft.archie.gui.composables.modal - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import net.kernelpanicsoft.archie.gui.animation.AnimationSpec -import net.kernelpanicsoft.archie.gui.animation.Easings -import net.kernelpanicsoft.archie.gui.animation.animateInt -import net.kernelpanicsoft.archie.gui.composables.basic.Text -import net.kernelpanicsoft.archie.gui.composables.containers.Surface -import net.kernelpanicsoft.archie.gui.composables.input.Button -import net.kernelpanicsoft.archie.gui.layer.ModalScope -import net.kernelpanicsoft.archie.gui.layout.Alignment -import net.kernelpanicsoft.archie.gui.layout.Arrangement -import net.kernelpanicsoft.archie.gui.layout.Column -import net.kernelpanicsoft.archie.gui.layout.Row -import net.kernelpanicsoft.archie.gui.modifiers.Modifier -import net.kernelpanicsoft.archie.gui.modifiers.position.margin -import net.kernelpanicsoft.archie.gui.modifiers.position.offset -import net.kernelpanicsoft.archie.gui.modifiers.position.padding -import net.kernelpanicsoft.archie.gui.modifiers.sizeIn -import net.kernelpanicsoft.archie.gui.theme.LocalTheme -import net.minecraft.network.chat.Component -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds - -private const val DIALOG_ANIMATION_MS = 180L - -/** - * A generic confirm/cancel modal with custom [content] and a slide/fade dismiss animation. - * - * Unlike [AlertDialog]/[PromptDialog]/[ChoiceDialog], [content] is fully custom rather than a - * fixed message layout. [net.kernelpanicsoft.archie.gui.layer.ModalScope.dismiss] is deferred until the close animation finishes so the modal - * doesn't disappear abruptly. - * - * @param title The dialog's header text. - * @param confirmText Label for the confirm button. - * @param cancelText Label for the cancel button. - * @param onConfirm Called immediately when the confirm button is pressed, before the close - * animation plays. - * @param onCancel Called immediately when the cancel button is pressed, before the close - * animation plays. - * @param content The dialog body, shown above the action row. - */ -@Composable -fun ModalScope.ConfirmDialog( - title: Component = Component.literal("Confirm Dialog"), - confirmText: Component = Component.literal("Confirm"), - cancelText: Component = Component.literal("Cancel"), - onConfirm: () -> Unit = {}, - onCancel: () -> Unit = {}, - content: @Composable () -> Unit -) -{ - val scope = rememberCoroutineScope() - var entered by remember { mutableStateOf(false) } - var closing by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { entered = true } - - fun closeWithAnimation(action: () -> Unit) { - if (closing) return - closing = true - entered = false - action() - scope.launch { - delay(DIALOG_ANIMATION_MS.milliseconds) - dismiss() - } - } - - val offsetY = animateInt( - targetValue = if (entered) 0 else 8, - spec = AnimationSpec(durationMillis = DIALOG_ANIMATION_MS.milliseconds, easing = Easings.OutCubic), - ) - - Surface(modifier = Modifier.padding(4).offset(x = 0, y = offsetY)) { - Column(modifier = Modifier.margin(4)) { - Text( - text = title, - modifier = Modifier.margin(bottom = 4), - color = LocalTheme.current.darkTextColor, - dropShadow = false - ) - content() - Row( - modifier = Modifier.margin(top = 4), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - Button( - onClick = { closeWithAnimation(onConfirm) }, - enabled = !closing, - modifier = Modifier.sizeIn(minWidth = 50, minHeight = 20) - ) { Text(confirmText) } - Button( - onClick = { closeWithAnimation(onCancel) }, - enabled = !closing, - modifier = Modifier.sizeIn(minWidth = 50, minHeight = 20) - ) { Text(cancelText) } - } - } - } -} \ No newline at end of file diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/DialogPrimitives.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/DialogPrimitives.kt index 5c9f56c76..9db249299 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/DialogPrimitives.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/modal/DialogPrimitives.kt @@ -16,6 +16,7 @@ import net.kernelpanicsoft.archie.gui.layout.Column import net.kernelpanicsoft.archie.gui.layout.Row import net.kernelpanicsoft.archie.gui.modifiers.Modifier import net.kernelpanicsoft.archie.gui.modifiers.position.margin +import net.kernelpanicsoft.archie.gui.modifiers.position.padding import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.gui.modifiers.width import net.kernelpanicsoft.archie.gui.theme.LocalTheme @@ -36,8 +37,10 @@ private fun ModalDialogScaffold( body: @Composable () -> Unit, actions: @Composable () -> Unit, ) { - Surface(modifier = modifier) { - Column(modifier = Modifier.margin(4), verticalArrangement = Arrangement.spacedBy(4)) { + // Padding on the Surface itself, not margin on the inner Column, matching ConfirmDialog - + // margin only grows the parent, it doesn't shrink what content measures against. + Surface(modifier = Modifier.padding(4).then(modifier)) { + Column(verticalArrangement = Arrangement.spacedBy(4), horizontalAlignment = Alignment.CenterHorizontally) { Text( text = title, color = LocalTheme.current.darkTextColor, @@ -53,6 +56,50 @@ private fun ModalDialogScaffold( } } +/** + * Generic confirm/cancel modal with fully custom [content], rather than a fixed message layout + * like [AlertDialog]/[PromptDialog]/[ChoiceDialog]. + * + * @param title The dialog's header text. + * @param confirmText Label for the confirm button. + * @param cancelText Label for the cancel button. + * @param onConfirm Called when the confirm button is pressed, just before the modal dismisses itself. + * @param onCancel Called when the cancel button is pressed, just before the modal dismisses itself. + * @param content The dialog body, shown above the action row. + */ +@Composable +fun ModalScope.ConfirmDialog( + title: Component = Component.literal("Confirm Dialog"), + confirmText: Component = Component.literal("Confirm"), + cancelText: Component = Component.literal("Cancel"), + onConfirm: () -> Unit = {}, + onCancel: () -> Unit = {}, + content: @Composable () -> Unit, +) { + ModalDialogScaffold( + title = title, + body = content, + actions = { + Button( + onClick = { + onConfirm() + dismiss() + }, + ) { + Text(confirmText, dropShadow = false) + } + Button( + onClick = { + onCancel() + dismiss() + }, + ) { + Text(cancelText, dropShadow = false) + } + }, + ) +} + /** * Simple one-action modal for acknowledgements and warnings. * @@ -75,10 +122,12 @@ fun ModalScope.AlertDialog( Text(text = message, dropShadow = false, color = LocalTheme.current.darkTextColor) }, actions = { - Button(onClick = { - onConfirm() - dismiss() - }) { + Button( + onClick = { + onConfirm() + dismiss() + }, + ) { Text(confirmText, dropShadow = false) } }, @@ -125,10 +174,12 @@ fun ModalScope.PromptDialog( } }, actions = { - Button(onClick = { - onCancel() - dismiss() - }) { + Button( + onClick = { + onCancel() + dismiss() + }, + ) { Text(cancelText, dropShadow = false) } Button( @@ -189,10 +240,12 @@ fun ModalScope.ChoiceDialog( } }, actions = { - Button(onClick = { - onCancel() - dismiss() - }) { + Button( + onClick = { + onCancel() + dismiss() + }, + ) { Text(cancelText, dropShadow = false) } }, diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/TextureStates.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/TextureStates.kt index 26404864d..53ae60239 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/TextureStates.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/TextureStates.kt @@ -6,7 +6,7 @@ import net.kernelpanicsoft.archie.gui.theme.ThemeState * Constant keys used to look up [ThemeState] entries within a [ComposableTheme]'s state map. * * Composables use these keys to select the correct texture variant based on their current - * interactive state (e.g. hovered, pressed, disabled). + * interactive state (e.g. focused, pressed, disabled). */ object TextureStates { /** The default idle state used when no other state applies. */ @@ -15,12 +15,17 @@ object TextureStates { /** Used when the composable is disabled and cannot be interacted with. */ const val DISABLED = "disabled" - /** Used when the mouse cursor is hovering over the composable. */ - const val HOVERED = "hovered" + /** + * Used when the composable is highlighted - the mouse cursor is hovering over it, or (see + * [WidgetState.focused]) it holds vanilla keyboard/controller focus. Both count as the same + * texture state: there's one "this is the thing about to be interacted with" visual, + * regardless of which input method put it there. + */ + const val FOCUSED = "focused" /** Used when the composable has been activated/checked/clicked (toggle state). */ const val CLICKED = "clicked" - /** Used when the composable is both activated and hovered simultaneously. */ - const val CLICKED_AND_HOVERED = "clicked_and_hovered" + /** Used when the composable is both activated and focused simultaneously. */ + const val CLICKED_AND_FOCUSED = "clicked_and_focused" } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/WidgetState.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/WidgetState.kt index 962570da7..52f795bd7 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/WidgetState.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/composables/theme/WidgetState.kt @@ -14,7 +14,7 @@ import net.kernelpanicsoft.archie.gui.theme.ComposableTheme * ```kotlin * val stateKey = WidgetState.resolve( * composableTheme, variant, - * WidgetState.clicked(checked), WidgetState.hovered(hovered), + * WidgetState.clicked(checked), WidgetState.focused(hovered || vanillaFocused), * enabled = enabled, * ) * node.renderState = stateKey @@ -23,15 +23,22 @@ import net.kernelpanicsoft.archie.gui.theme.ComposableTheme */ object WidgetState { /** - * One named boolean axis of a widget's interaction state (e.g. "hovered" paired with - * whether the pointer currently is), in the priority order [resolve] should consider it - + * One named boolean axis of a widget's interaction state (e.g. "focused" paired with + * whether the widget currently is), in the priority order [resolve] should consider it - * pass axes to [resolve] most-significant first (typically the "activated" axis - checked/ - * selected/pressed - before "hovered"). + * selected/pressed - before "focused"). */ data class Axis(val name: String, val active: Boolean) - /** An [Axis] for [TextureStates.HOVERED]. */ - fun hovered(active: Boolean) = Axis(TextureStates.HOVERED, active) + /** + * An [Axis] for [TextureStates.FOCUSED] - whether the widget is currently highlighted, + * meaning either the mouse is hovering it *or* it holds vanilla keyboard/controller focus + * (see `Modifier.focusable`). Both drive the same texture state: there's one "this is the + * thing about to be interacted with" visual regardless of which input method put it there, + * so callers that support both pass a single merged boolean (e.g. `isHovered || isFocused`) + * rather than two independent axes. + */ + fun focused(active: Boolean) = Axis(TextureStates.FOCUSED, active) /** An [Axis] for [TextureStates.CLICKED] - a checkbox/switch/radio's checked-or-selected state, a button/tab's pressed-or-selected state, or a slider's dragging state. */ fun clicked(active: Boolean) = Axis(TextureStates.CLICKED, active) @@ -45,8 +52,8 @@ object WidgetState { * weren't a factor - matching every existing chain's behavior of only branching on * `!enabled` where a `disabled` theme state actually exists to show. * - Otherwise, tries the most specific composite key first: every currently-active axis's - * [Axis.name], joined by `"_and_"` in priority order (e.g. `"clicked_and_hovered"` for - * [clicked]+[hovered] both active). If [theme] doesn't define that combination, falls + * [Axis.name], joined by `"_and_"` in priority order (e.g. `"clicked_and_focused"` for + * [clicked]+[focused] both active). If [theme] doesn't define that combination, falls * back one axis at a time - by priority, i.e. trying each individual active axis's own * key alone, highest priority first - stopping at the first one [theme] defines. * - Returns [TextureStates.DEFAULT] if no active axis (alone or combined) has a defined @@ -54,15 +61,15 @@ object WidgetState { * * This graceful per-axis fallback (rather than jumping straight from the full composite to * [TextureStates.DEFAULT]) generalizes what `Button`'s chain alone used to do by hand - * (falling through a missing "clicked" state to "hovered" - `button.json` defines no + * (falling through a missing "clicked" state to "focused" - `button.json` defines no * "clicked" state at all) - every caller gets it for free, without needing its own * `hasState` check. * * **Tab note:** `TabContainer.kt`'s old chain computed its "clicked" axis from - * `selected || isPressed`, but only paired it with `hovered` into the combined state when + * `selected || isPressed`, but only paired it with `focused` into the combined state when * specifically `selected` was true - a pressed-but-unselected-and-hovered tab silently lost * its hover visual. Callers migrating to this resolver should pass a single `clicked` axis - * (`selected || isPressed`) and a separate `hovered` axis as normal; [resolve] then treats + * (`selected || isPressed`) and a separate `focused` axis as normal; [resolve] then treats * both uniformly like every other component, which is a deliberate behavior fix, not an * incidental one. */ diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/focus/BringIntoView.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/focus/BringIntoView.kt new file mode 100644 index 000000000..b39aa219a --- /dev/null +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/focus/BringIntoView.kt @@ -0,0 +1,21 @@ +package net.kernelpanicsoft.archie.gui.focus + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import net.kernelpanicsoft.archie.gui.layout.LayoutNode + +/** + * Adjusts scroll position (or otherwise) so a focused descendant's bounds become visible - the + * `net.kernelpanicsoft.archie` equivalent of Compose Foundation's `BringIntoViewRequester`/ + * `BringIntoViewParent` mechanism. `Scrollable` provides one automatically; `Modifier.focusable`'s + * vanilla-focus bridge calls it whenever a descendant gains focus, so Tab-navigating to a node + * scrolled out of view brings it back into the viewport, the same as a real browser or Android + * view does. + */ +fun interface BringIntoViewParent { + /** Called with a descendant [LayoutNode] that just gained focus. */ + fun bringIntoView(node: LayoutNode) +} + +/** Provides the nearest ancestor `Scrollable`'s [BringIntoViewParent], if any. */ +val LocalBringIntoViewParent: ProvidableCompositionLocal = compositionLocalOf { null } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/focus/LayoutNodeFocusAdapter.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/focus/LayoutNodeFocusAdapter.kt new file mode 100644 index 000000000..b3e84938d --- /dev/null +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/focus/LayoutNodeFocusAdapter.kt @@ -0,0 +1,95 @@ +package net.kernelpanicsoft.archie.gui.focus + +import net.kernelpanicsoft.archie.gui.layout.LayoutNode +import net.kernelpanicsoft.archie.gui.modifiers.input.CharEvent +import net.kernelpanicsoft.archie.gui.modifiers.input.FocusableModifier +import net.kernelpanicsoft.archie.gui.modifiers.input.KeyEvent +import net.kernelpanicsoft.archie.gui.modifiers.input.OnCharTypedModifier +import net.kernelpanicsoft.archie.gui.modifiers.input.OnKeyEventModifier +import net.minecraft.client.gui.ComponentPath +import net.minecraft.client.gui.components.events.GuiEventListener +import net.minecraft.client.gui.navigation.FocusNavigationEvent +import net.minecraft.client.gui.navigation.ScreenRectangle + +/** + * Bridges one [FocusableModifier]-carrying [LayoutNode] into vanilla Minecraft's + * `GuiEventListener` focus graph, so vanilla's Tab/Shift-Tab and arrow-key navigation - and + * anything else that walks `GuiEventListener`, e.g. Controlify's controller navigation - + * reaches Compose content the same way it reaches an ordinary `AbstractWidget`. + * + * `ComposeScreen`/`ComposeContainerScreen` rebuild a fresh list of adapters on every + * `children()` call, since the [LayoutNode] tree can change between frames. Vanilla's own + * `ContainerEventHandler.handleTabNavigation` looks up the previously-focused + * `GuiEventListener` by `indexOf` in that freshly rebuilt list, so [equals]/[hashCode] are + * keyed on the wrapped node's identity rather than the adapter's - two adapters wrapping the + * same node must compare equal across separate `children()` calls, or Tab would never be able + * to tell "the currently focused thing" apart from "a brand new list entry" and always restart + * from the beginning. + */ +class LayoutNodeFocusAdapter(val node: LayoutNode) : GuiEventListener { + + private val focusable: FocusableModifier? + get() = node.get() + + override fun equals(other: Any?): Boolean = other is LayoutNodeFocusAdapter && other.node === node + override fun hashCode(): Int = System.identityHashCode(node) + + override fun getRectangle(): ScreenRectangle { + val (x, y) = node.absoluteCoords + return ScreenRectangle(x, y, node.width, node.height) + } + + override fun isFocused(): Boolean = focusable?.focused?.value == true + + override fun setFocused(focused: Boolean) { + val f = focusable ?: return + f.setFocused(focused) + // A redundant call while already focused (e.g. ComponentPath.Path.applyFocus calling + // this twice for one logical focus change) is harmless here - bringing an + // already-visible node into view again is a no-op. + if (focused) f.bringIntoViewParent?.bringIntoView(node) + } + + // The GuiEventListener default always returns null - AbstractWidget overrides it the same + // way, to actually offer itself as a leaf when not already focused. Without this override, + // ContainerEventHandler's Tab/arrow-key search (which calls nextFocusPath on every + // candidate from children(), not just membership-checks it) would never select anything + // here, silently leaving Tab navigation a no-op. + override fun nextFocusPath(event: FocusNavigationEvent): ComponentPath? = + if (!isFocused()) ComponentPath.leaf(this) else null + + /** Forwards to [node]'s own [OnKeyEventModifier] chain - the same one the broadcast key dispatch uses. */ + override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + val event = KeyEvent(keyCode, scanCode, modifiers) + node.modifier.foldIn(Unit) { _, el -> + if (el is OnKeyEventModifier && !event.isConsumed) el.onEvent(node, event) + } + return event.isConsumed + } + + /** Forwards to [node]'s own [OnCharTypedModifier] chain - the same one the broadcast char dispatch uses. */ + override fun charTyped(codePoint: Char, modifiers: Int): Boolean { + val event = CharEvent(codePoint, modifiers) + node.modifier.foldIn(Unit) { _, el -> + if (el is OnCharTypedModifier && !event.isConsumed) el.onEvent(node, event) + } + return event.isConsumed + } +} + +/** + * Collects every [FocusableModifier]-carrying [LayoutNode] in [root]'s subtree, each wrapped + * as a [LayoutNodeFocusAdapter], in composition (depth-first, declaration) order - the order + * vanilla's Tab-navigation (`ContainerEventHandler.handleTabNavigation`) walks by default. + * + * Backs `ComposeScreen`/`ComposeContainerScreen`'s `children()` override, the one hook that + * lets vanilla's built-in Tab/Shift-Tab/arrow-key navigation - and anything else that walks + * `GuiEventListener`, e.g. Controlify - see Compose content at all. + */ +internal fun collectFocusableChildren(root: LayoutNode): List = buildList { + fun visit(node: LayoutNode) { + if (node.get() != null) add(LayoutNodeFocusAdapter(node)) + node.children.toList().forEach(::visit) + } + visit(root) +} diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/interaction/Interaction.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/interaction/Interaction.kt new file mode 100644 index 000000000..2d8b90366 --- /dev/null +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/interaction/Interaction.kt @@ -0,0 +1,41 @@ +package net.kernelpanicsoft.archie.gui.interaction + +/** + * A discrete input interaction a component emits about itself through its + * [MutableInteractionSource] - modeled on Jetpack Compose's `Interaction`/`InteractionSource`. + */ +sealed interface Interaction + +/** Interactions describing a pointer press. */ +sealed interface PressInteraction : Interaction { + /** The pointer went down inside the component's bounds. */ + data object Press : PressInteraction + + /** The pointer was released after a [Press]. */ + data object Release : PressInteraction + + /** The press ended without a [Release] (e.g. the pointer left the component's bounds). */ + data object Cancel : PressInteraction +} + +/** Interactions describing pointer hover. */ +sealed interface HoverInteraction : Interaction { + data object Enter : HoverInteraction + data object Exit : HoverInteraction +} + +/** + * Interactions describing vanilla keyboard/controller focus - see + * [net.kernelpanicsoft.archie.gui.modifiers.input.focusable]. + */ +sealed interface FocusInteraction : Interaction { + data object Focus : FocusInteraction + data object Unfocus : FocusInteraction +} + +/** Interactions describing a pointer drag. */ +sealed interface DragInteraction : Interaction { + data object Start : DragInteraction + data object Stop : DragInteraction + data object Cancel : DragInteraction +} diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/interaction/InteractionSource.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/interaction/InteractionSource.kt new file mode 100644 index 000000000..8c89d70ad --- /dev/null +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/interaction/InteractionSource.kt @@ -0,0 +1,94 @@ +package net.kernelpanicsoft.archie.gui.interaction + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * A read-only stream of [Interaction]s a component emits about itself (press, hover, focus, + * drag). Several independent pieces of UI - the widget's own visuals, a shared "indication" + * effect, a test assertion - can all observe the same interaction state this way, instead of + * each hand-rolling its own separate hovered/pressed/focused booleans. + */ +interface InteractionSource { + val interactions: Flow +} + +/** An [InteractionSource] that can also emit new [Interaction]s into itself. */ +interface MutableInteractionSource : InteractionSource { + /** Emits [interaction] without suspending. Never fails to enqueue - see [MutableInteractionSourceImpl]. */ + fun tryEmit(interaction: Interaction): Boolean +} + +/** Creates a new, independent [MutableInteractionSource]. */ +fun MutableInteractionSource(): MutableInteractionSource = MutableInteractionSourceImpl() + +private class MutableInteractionSourceImpl : MutableInteractionSource { + // DROP_OLDEST (not the default SUSPEND, which makes a non-suspending tryEmit() on a full + // buffer fail and silently drop the *newest* interaction instead) - a state-defining + // interaction like FocusInteraction.Focus/Unfocus has to never go missing, since + // collectIsFocusedAsState() only reduces off whatever interactions it actually receives. + // A missed Unfocus is exactly "sometimes Tab-focusing doesn't visually update" (a stale + // collector - e.g. rapid Tab presses outrunning the Recomposer's own dispatch - could + // previously fill the 16-slot buffer and start rejecting new emissions outright). Losing a + // stale buffered event this way is harmless since only the latest of each interaction type + // ever matters to the reducer; losing the newest one wasn't. + private val flow = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val interactions: Flow = flow + override fun tryEmit(interaction: Interaction): Boolean = flow.tryEmit(interaction) +} + +/** Subscribes to [InteractionSource.interactions], reducing matching interactions to a [State]. */ +@Composable +private fun InteractionSource.collectAsState(initial: T, reduce: (T, Interaction) -> T): State { + val state = remember(this) { mutableStateOf(initial) } + LaunchedEffect(this) { + interactions.collect { interaction -> state.value = reduce(state.value, interaction) } + } + return state +} + +/** `true` while a [PressInteraction.Press] is active (until its [PressInteraction.Release]/[PressInteraction.Cancel]). */ +@Composable +fun InteractionSource.collectIsPressedAsState(): State = collectAsState(false) { current, interaction -> + when (interaction) { + is PressInteraction.Press -> true + is PressInteraction.Release, is PressInteraction.Cancel -> false + else -> current + } +} + +/** `true` while the pointer is hovering the component. */ +@Composable +fun InteractionSource.collectIsHoveredAsState(): State = collectAsState(false) { current, interaction -> + when (interaction) { + is HoverInteraction.Enter -> true + is HoverInteraction.Exit -> false + else -> current + } +} + +/** `true` while the component holds vanilla keyboard/controller focus. */ +@Composable +fun InteractionSource.collectIsFocusedAsState(): State = collectAsState(false) { current, interaction -> + when (interaction) { + is FocusInteraction.Focus -> true + is FocusInteraction.Unfocus -> false + else -> current + } +} + +/** `true` while a drag is active (until its [DragInteraction.Stop]/[DragInteraction.Cancel]). */ +@Composable +fun InteractionSource.collectIsDraggedAsState(): State = collectAsState(false) { current, interaction -> + when (interaction) { + is DragInteraction.Start -> true + is DragInteraction.Stop, is DragInteraction.Cancel -> false + else -> current + } +} diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt index f35ea05ef..bf8989aad 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import kotlinx.serialization.serializer +import net.kernelpanicsoft.archie.serialization.SerializationManager /** * Provides the current [ComposeItemContainerMenu]'s state to composables in the composition @@ -45,5 +46,5 @@ inline fun observeItemProperty( initialValue: T? = null, ): MutableState { val state = LocalItemState.current ?: throw RuntimeException("No item container state available in composition") - return state.observeProperty(propertyName, serializer(), initialValue) + return state.observeProperty(propertyName, SerializationManager.module.serializer(), initialValue) } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layer/LayerStackManager.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layer/LayerStackManager.kt index ffd26cd2f..8a8ac322b 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layer/LayerStackManager.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layer/LayerStackManager.kt @@ -34,6 +34,7 @@ import net.kernelpanicsoft.archie.gui.modifiers.input.PointerEventType import net.kernelpanicsoft.archie.gui.modifiers.input.onPointerEvent import net.kernelpanicsoft.archie.gui.modifiers.position.offset import net.kernelpanicsoft.archie.gui.nodes.UINode +import net.kernelpanicsoft.archie.gui.theme.ThemeData import net.minecraft.network.chat.Component import java.util.* import kotlinx.coroutines.delay @@ -55,6 +56,14 @@ val LocalLayerManager = compositionLocalOf { error("No LayerManager provided. Are you inside a ComposeScreen?") } +/** + * Like [LocalLayerManager], but `null` instead of throwing when there's no [LayerStackManager] + * in scope - for code that wants to *opportunistically* interact with one (e.g. [net.kernelpanicsoft.archie.gui.theme.Theme] + * publishing [LayerStackManager.rootTheme]) without requiring every caller to run inside a + * [net.kernelpanicsoft.archie.gui.ComposeScreen]. + */ +val LocalLayerManagerOrNull = compositionLocalOf { null } + /** The depth index of the currently composed layer (base layer is `0`). */ val LocalLayerDepth = compositionLocalOf { 0 } @@ -87,12 +96,44 @@ data class ModalTransitionSpec( * * @param parentComposition The [CompositionContext] from the host screen, required * when creating child [Composition]s for each layer. + * @param screenLocals Wraps every layer's content in whatever `CompositionLocalProvider` + * the host screen needs visible screen-wide (e.g. `LocalScreen`, `LocalVanillaScreen`, + * [LocalLayerManager]). Each layer is its own top-level [Composition] + * parented directly to [parentComposition] rather than nested inside another layer's, so + * ordinary composition-local scoping - a `CompositionLocalProvider` wrapping only the base + * layer's own content, say - never reaches a later-pushed modal/dropdown/tooltip layer. + * [push] applies this to *every* layer it creates so all such locals stay implicitly shared + * across the whole stack instead of each caller needing to remember which ones to re-supply. */ -class LayerStackManager(private val parentComposition: CompositionContext) { +class LayerStackManager( + private val parentComposition: CompositionContext, + private val screenLocals: @Composable (content: @Composable () -> Unit) -> Unit, +) { /** The ordered list of active layers. Layers are rendered bottom-to-top. */ val layers = mutableStateListOf() + /** + * Every [net.kernelpanicsoft.archie.gui.theme.Theme] scope currently mounted anywhere in + * this screen's base layer, in mount order (outermost first) - pushed/popped by + * [net.kernelpanicsoft.archie.gui.theme.Theme] itself via [LocalLayerManagerOrNull], so a + * nested `Theme {}` override sits on top of whatever it's nested inside while it's mounted, + * and the outer one resumes as [rootTheme] once it unmounts. See [rootTheme]. + */ + internal val themeStack = mutableStateListOf() + + /** + * The [ThemeData][net.kernelpanicsoft.archie.gui.theme.ThemeData] a newly-pushed layer + * (modal, dropdown, tooltip) should inherit: the innermost [net.kernelpanicsoft.archie.gui.theme.Theme] + * scope currently mounted in this screen's base layer, per [themeStack]. `null` until the + * base layer's first `Theme {}` mounts. Read back by the screen's `screenLocals` wrapping so + * every later-pushed layer inherits it too, instead of silently falling back to + * [net.kernelpanicsoft.archie.gui.theme.LocalTheme]'s own default - each later layer is its + * own top-level [androidx.compose.runtime.Composition] (see this class's own doc), so it + * would otherwise never see a `Theme {}` that only wraps the base layer's content. + */ + val rootTheme: ThemeData? get() = themeStack.lastOrNull() + /** * Represents the total size of the screen, calculated based on the dimensions of all active layers. * @@ -142,8 +183,10 @@ class LayerStackManager(private val parentComposition: CompositionContext) { val layerId = UUID.randomUUID() val layerDepth = layers.size val layer = Layer(id = layerId, parentComposition = parentComposition, depth = layerDepth) { - CompositionLocalProvider(LocalLayerDepth provides layerDepth) { - layerContent { popById(layerId) } + screenLocals { + CompositionLocalProvider(LocalLayerDepth provides layerDepth) { + layerContent { popById(layerId) } + } } } layers.add(layer) diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layout/IntCoordinates.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layout/IntCoordinates.kt index 04d912239..11325cb65 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layout/IntCoordinates.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/layout/IntCoordinates.kt @@ -16,7 +16,11 @@ value class IntCoordinates(val pair: Long) { operator fun component1() = x operator fun component2() = y - constructor(x: Int, y: Int) : this((x.toLong() shl 32) or y.toLong()) + // y.toLong() alone sign-extends a negative y across the upper 32 bits - the ones this OR + // packs x into - clobbering x to -1 regardless of its real value. Masking to the low 32 + // bits keeps y's packed bit pattern (still decoded correctly by pair.toInt(), which only + // ever reads those same low bits) without corrupting x's half. + constructor(x: Int, y: Int) : this((x.toLong() shl 32) or (y.toLong() and 0xFFFFFFFFL)) override fun toString(): String = "($x, $y)" @@ -40,7 +44,8 @@ value class IntSize(val pair: Long) { operator fun component1() = width operator fun component2() = height - constructor(width: Int, height: Int) : this((width.toLong() shl 32) or height.toLong()) + // See IntCoordinates' identically-shaped constructor for why height must be masked here too. + constructor(width: Int, height: Int) : this((width.toLong() shl 32) or (height.toLong() and 0xFFFFFFFFL)) override fun toString(): String = "($width, $height)" } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/modifiers/input/Interactable.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/modifiers/input/Interactable.kt new file mode 100644 index 000000000..59e68e40a --- /dev/null +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/modifiers/input/Interactable.kt @@ -0,0 +1,244 @@ +package net.kernelpanicsoft.archie.gui.modifiers.input + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import net.kernelpanicsoft.archie.gui.focus.BringIntoViewParent +import net.kernelpanicsoft.archie.gui.focus.LocalBringIntoViewParent +import net.kernelpanicsoft.archie.gui.interaction.DragInteraction +import net.kernelpanicsoft.archie.gui.interaction.FocusInteraction +import net.kernelpanicsoft.archie.gui.interaction.HoverInteraction +import net.kernelpanicsoft.archie.gui.interaction.MutableInteractionSource +import net.kernelpanicsoft.archie.gui.interaction.PressInteraction +import net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState +import net.kernelpanicsoft.archie.gui.modifiers.Modifier +import net.kernelpanicsoft.archie.gui.nodes.UINode +import org.lwjgl.glfw.GLFW + +/** GLFW key codes that activate a focused widget, mirroring vanilla `AbstractWidget` activation. */ +internal val ACTIVATION_KEYS: IntArray = intArrayOf(GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER, GLFW.GLFW_KEY_SPACE) + +/** + * Marks a composable as a stop in vanilla Minecraft's built-in focus-navigation graph. + * + * A node carrying this modifier is exposed as a synthetic `GuiEventListener` leaf from + * `ComposeScreen`/`ComposeContainerScreen`'s `children()` override (see + * `net.kernelpanicsoft.archie.gui.focus.LayoutNodeFocusAdapter`), so vanilla's own Tab/ + * Shift-Tab and arrow-key navigation - and anything else that walks `GuiEventListener`, e.g. + * Controlify's controller-driven `ScreenProcessor` - can reach it exactly like an ordinary + * `AbstractWidget`. + * + * @property focused Backing focus state: vanilla writes to it via `setFocused`. + * @property interactionSource When set, [FocusInteraction.Focus]/[FocusInteraction.Unfocus] is + * emitted alongside every [focused] write, so callers can observe focus the same way as + * press/hover/drag (see [collectIsFocusedAsState][net.kernelpanicsoft.archie.gui.interaction.collectIsFocusedAsState]). + * @property bringIntoViewParent The nearest ancestor `Scrollable`'s [BringIntoViewParent], if + * any - called on focus gain so Tab-navigating to a scrolled-out-of-view node brings it back + * into the viewport. + */ +internal data class FocusableModifier( + val focused: MutableState, + val interactionSource: MutableInteractionSource? = null, + val bringIntoViewParent: BringIntoViewParent? = null, +) : Modifier.Element { + override fun mergeWith(other: FocusableModifier): FocusableModifier = other + override fun toString(): String = "FocusableModifier(focused=${focused.value})" + + /** Writes [value] to [focused] and emits the matching [FocusInteraction], if it actually changed. */ + fun setFocused(value: Boolean) { + if (focused.value == value) return + focused.value = value + interactionSource?.tryEmit(if (value) FocusInteraction.Focus else FocusInteraction.Unfocus) + } +} + +/** + * Registers this composable as a vanilla keyboard/controller focus-navigation stop - the + * `net.kernelpanicsoft.archie` equivalent of Compose Foundation's `Modifier.focusable`. + * + * Unlike Android's, this can't be backed by an internal `Modifier.Node` (this framework's + * modifiers are plain immutable data, not stateful nodes), so the focus flag is [remember]ed + * here instead - transparent to the caller, but it does mean this overload must be called from + * a `@Composable` context, same as any other modifier factory that needs to hold state. + * + * [Clickable][net.kernelpanicsoft.archie.gui.composables.input.Clickable] already applies this + * (plus Enter/Space activation) for any click-driven widget; reach for this directly only when + * building an input with a different activation model (e.g. `SliderCore`, which nudges its + * value on arrow keys instead). + * + * @param enabled When `false`, this node drops out of the focus graph entirely - + * mirrors `AbstractWidget.active` keeping a disabled vanilla widget out of Tab order. + * @param interactionSource Optional sink for [FocusInteraction.Focus]/[FocusInteraction.Unfocus]. + */ +@Composable +fun Modifier.focusable(enabled: Boolean = true, interactionSource: MutableInteractionSource? = null): Modifier { + val focused = remember { mutableStateOf(false) } + val bringIntoViewParent = LocalBringIntoViewParent.current + return if (enabled) { + this then FocusableModifier(focused, interactionSource, bringIntoViewParent) + } else { + focused.value = false + this + } +} + +/** + * Emits [HoverInteraction] as the pointer enters/exits - matches Compose Foundation's own + * `Modifier.hoverable(interactionSource, enabled)` signature exactly. Observe the result via + * [net.kernelpanicsoft.archie.gui.interaction.collectIsHoveredAsState], same as Android; this + * has no enter/exit callbacks of its own to hook into. + */ +fun Modifier.hoverable( + interactionSource: MutableInteractionSource, + enabled: Boolean = true, +): Modifier = this + .onPointerEvent(PointerEventType.ENTER) { _, e -> + if (enabled) interactionSource.tryEmit(HoverInteraction.Enter) + e.consume() + } + .onPointerEvent(PointerEventType.EXIT) { _, e -> + interactionSource.tryEmit(HoverInteraction.Exit) + if (enabled) e.consume() + } + +/** + * Emits [PressInteraction] and invokes [onPress]/[onRelease] across a press - the + * `net.kernelpanicsoft.archie` equivalent of the press-recognition half of Compose + * Foundation's `Modifier.clickable`. Observe release via + * [net.kernelpanicsoft.archie.gui.interaction.collectIsPressedAsState] rather than a callback - + * [onPress] is kept only because, unlike hover, a press is inherently an action (the click + * itself), not just state to observe. + */ +fun Modifier.pressable( + interactionSource: MutableInteractionSource, + enabled: Boolean = true, + onPress: (UINode) -> Unit, +): Modifier = this + .onPointerEvent(PointerEventType.PRESS) { node, e -> + if (!enabled) return@onPointerEvent + interactionSource.tryEmit(PressInteraction.Press) + onPress(node) + e.consume(true) + } + .onPointerEvent(PointerEventType.GLOBAL_RELEASE) { _, _ -> + interactionSource.tryEmit(PressInteraction.Release) + } + +/** Which axis a [draggable] gesture tracks - the `net.kernelpanicsoft.archie` equivalent of Compose Foundation's `Orientation`. */ +enum class Orientation { Horizontal, Vertical } + +/** + * Reports drag deltas to a single callback - the `net.kernelpanicsoft.archie` equivalent of + * Compose Foundation's `DraggableState`. Create one with [rememberDraggableState] rather than + * implementing this directly. + */ +fun interface DraggableState { + /** Called with the drag delta, in pixels along the gesture's [Orientation], for each drag update. */ + fun dispatchRawDelta(delta: Float) +} + +/** Remembers a [DraggableState] that forwards each delta to the latest [onDelta]. */ +@Composable +fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState { + val onDeltaState = rememberUpdatedState(onDelta) + return remember { DraggableState { delta -> onDeltaState.value(delta) } } +} + +/** + * Recognizes a drag gesture along [orientation] and reports each movement as a delta to + * [state] - the `net.kernelpanicsoft.archie` equivalent of Compose Foundation's + * `Modifier.draggable(state, orientation, enabled, interactionSource, onDragStarted, + * onDragStopped)` (dropping `startDragImmediately`/`reverseDirection`, which have no + * equivalent concept here). + * + * Reports only a *delta* per movement, not an absolute position - a widget whose drag also + * needs to jump to an absolute position on the initial press (e.g. `SliderCore`'s "click the + * track to jump there") needs its own press handling for that press-time jump; [state] only + * covers the continuous drag that follows, same as Compose Foundation's own `Slider` doesn't + * build on plain `Modifier.draggable` either, for the same reason. + */ +fun Modifier.draggable( + state: DraggableState, + orientation: Orientation, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + onDragStarted: () -> Unit = {}, + onDragStopped: () -> Unit = {}, +): Modifier = this + .onPointerEvent(PointerEventType.PRESS) { _, e -> + if (!enabled) return@onPointerEvent + interactionSource?.tryEmit(DragInteraction.Start) + onDragStarted() + e.consume(true) + } + .onDrag { _, e -> + if (!enabled) return@onDrag + state.dispatchRawDelta((if (orientation == Orientation.Horizontal) e.dragX else e.dragY).toFloat()) + e.consume() + } + .onPointerEvent(PointerEventType.GLOBAL_RELEASE) { _, _ -> + interactionSource?.tryEmit(DragInteraction.Stop) + onDragStopped() + } + +/** + * Marks this composable as a togglable boolean control (checkbox/switch) - the + * `net.kernelpanicsoft.archie` equivalent of Compose Foundation's `Modifier.toggleable`. + * + * Combines [focusable], [hoverable], and [pressable] into one modifier applied directly to the + * widget's own node - calling [onValueChange] with `!value` on press or Enter/Space while + * focused - so a toggle control doesn't need [Clickable][net.kernelpanicsoft.archie.gui.composables.input.Clickable]'s + * extra wrapping container just to participate in focus/hover/press. + */ +@Composable +fun Modifier.toggleable( + value: Boolean, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onValueChange: (Boolean) -> Unit, +): Modifier { + val isFocused by interactionSource.collectIsFocusedAsState() + return this + .focusable(enabled = enabled, interactionSource = interactionSource) + .onKeyEvent { _, e -> + if (enabled && isFocused && e.keyCode in ACTIVATION_KEYS) { + onValueChange(!value) + e.consume(bypassSuperCall = true) + } + } + .hoverable(interactionSource, enabled = enabled) + .pressable(interactionSource, enabled = enabled, onPress = { onValueChange(!value) }) +} + +/** + * Marks this composable as a selectable option within a mutually exclusive group (radio + * button/tab) - the `net.kernelpanicsoft.archie` equivalent of Compose Foundation's + * `Modifier.selectable`. + * + * Same shape as [toggleable], but calls [onClick] unconditionally rather than toggling a + * boolean - "clicking an already-selected option is a no-op" is the caller's own + * responsibility (e.g. `RadioButtonCore` passing `onSelect = { if (!selected) onSelect() }`), + * matching real Android's `selectable` the same way. + */ +@Composable +fun Modifier.selectable( + selected: Boolean, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onClick: () -> Unit, +): Modifier { + val isFocused by interactionSource.collectIsFocusedAsState() + return this + .focusable(enabled = enabled, interactionSource = interactionSource) + .onKeyEvent { _, e -> + if (enabled && isFocused && e.keyCode in ACTIVATION_KEYS) { + onClick() + e.consume(bypassSuperCall = true) + } + } + .hoverable(interactionSource, enabled = enabled) + .pressable(interactionSource, enabled = enabled, onPress = { onClick() }) +} diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/nodes/UINode.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/nodes/UINode.kt index e066a0da1..b23ebdf28 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/nodes/UINode.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/nodes/UINode.kt @@ -35,7 +35,7 @@ interface UINode { /** * The [net.kernelpanicsoft.archie.gui.composables.theme.TextureStates] key a stateful - * [Renderer] most recently selected to draw (e.g. `"hovered"`, `"clicked_and_hovered"`), + * [Renderer] most recently selected to draw (e.g. `"focused"`, `"clicked_and_focused"`), * or `null` for nodes that don't render theme-state-driven visuals. * * Set by the [Renderer] itself, purely as a test hook - lets a client GameTest assert which diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/ComposableTheme.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/ComposableTheme.kt index cf7feb180..bf8f35a6f 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/ComposableTheme.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/ComposableTheme.kt @@ -6,9 +6,14 @@ import kotlinx.serialization.Serializable import net.kernelpanicsoft.archie.Archie import net.kernelpanicsoft.archie.gui.composables.theme.TextureStates import net.kernelpanicsoft.archie.gui.layout.Size +import net.kernelpanicsoft.archie.gui.modifiers.Modifier +import net.kernelpanicsoft.archie.gui.modifiers.position.padding +import net.kernelpanicsoft.archie.gui.modifiers.sizeIn import net.kernelpanicsoft.archie.resourcepacks.SerializationReloadListener import net.kernelpanicsoft.archie.serialization.SerializationManager import net.kernelpanicsoft.archie.serialization.serializers.SResourceLocation +import net.kernelpanicsoft.archie.util.div +import net.kernelpanicsoft.archie.util.plus import net.minecraft.resources.ResourceLocation import net.minecraft.server.packs.resources.PreparableReloadListener import net.minecraft.server.packs.resources.ResourceManager @@ -53,11 +58,27 @@ data class SimpleThemeState( @Serializable data class StatefulTheme(val states: Map) +/** + * Inner spacing a composable using a theme should reserve around its own [content], so e.g. a + * button's label never renders flush against the button's edges once it grows past + * [ComposableTheme.minSize] to fit a longer label. + * + * @property horizontal Padding applied to both the left and right sides. + * @property vertical Padding applied to both the top and bottom sides. + */ +@Serializable +data class ThemePadding( + val horizontal: Int = 0, + val vertical: Int = 0, +) + /** Raw JSON shape of a theme file, deserialized as-is and resolved by [ThemeResourceListener] into a [ComposableTheme]. */ @Serializable data class RawComposableTheme( val states: Map = emptyMap(), val variants: Map> = emptyMap(), + @SerialName("min_size") val minSize: Size? = null, + @SerialName("content_padding") val contentPadding: ThemePadding? = null, ) /** Raw JSON shape of a single theme state; fields left `null` inherit from the state's `"default"` entry. */ @@ -96,16 +117,25 @@ private data class GuiScalingMetadata( * Contains base [states] and optional named [variants] (e.g. `"dark"`). * * @property isNineslice Whether [states]' default texture is nine-slice scaled, per its - * `.mcmeta` sprite metadata. When `false`, composables using this theme get a minimum - * size matching the sprite's own pixel dimensions instead of stretching arbitrarily. + * `.mcmeta` sprite metadata. When `false` and [minSize] isn't set, composables using this + * theme get a minimum size matching the sprite's own pixel dimensions instead of stretching + * arbitrarily. * @property states Base state map (always contains at least `"default"`). * @property variants Named variant overrides (e.g. `"dark"` → its own state map). + * @property minSize Explicit intrinsic minimum size composables using this theme should + * enforce, regardless of [isNineslice] - e.g. a nine-slice button texture still wants a + * sensible minimum clickable area even though its sprite can stretch to any size. Takes + * priority over the sprite-size fallback described under [isNineslice] when set. + * @property contentPadding Inner spacing composables using this theme should reserve around + * their own content, so content grown past [minSize] doesn't render flush against the edges. */ @Serializable data class ComposableTheme( val isNineslice: Boolean = false, val states: Map, val variants: Map = emptyMap(), + val minSize: Size? = null, + val contentPadding: ThemePadding? = null, ) { companion object { /** @@ -136,6 +166,27 @@ data class ComposableTheme( (variantName?.let { variants[it] }?.states?.get(stateName) ?: states[stateName]) != null } +/** + * The [Modifier.sizeIn] floor a composable using this theme should apply to its own layout + * node: [ComposableTheme.minSize] when the theme declares one, otherwise the `"default"` + * state's own sprite dimensions for a non-nine-slice texture (which can't stretch without + * distorting), otherwise no floor at all - a nine-slice texture with no explicit [ComposableTheme.minSize] + * is free to shrink or stretch to fit its content. + */ +fun ComposableTheme.intrinsicSizeModifier(): Modifier { + minSize?.let { return Modifier.sizeIn(minWidth = it.width, minHeight = it.height) } + if (isNineslice) return Modifier + val default = states[TextureStates.DEFAULT] as SimpleThemeState + return Modifier.sizeIn(minWidth = default.width, minHeight = default.height) +} + +/** + * The [Modifier.padding] a composable using this theme should reserve around its own content, + * per [ComposableTheme.contentPadding] - a no-op [Modifier] when the theme doesn't declare one. + */ +fun ComposableTheme.contentPaddingModifier(): Modifier = + contentPadding?.let { Modifier.padding(horizontal = it.horizontal, vertical = it.vertical) } ?: Modifier + /* ─────────────────────── Reload listener ─────────────────────── */ /** @@ -155,8 +206,10 @@ data class ComposableTheme( * "width": 64, * "height": 20 * }, - * "hovered": { "texture": "archie:java/button_highlighted" } - * } + * "focused": { "texture": "archie:java/button_highlighted" } + * }, + * "min_size": { "width": 50, "height": 20 }, + * "content_padding": { "horizontal": 4, "vertical": 2 } * } * ``` */ @@ -211,10 +264,10 @@ class ThemeResourceListener : } val isNineslice = resourceManager.isNineSliceTexture(defaultState.texture) - COMPOSABLES[location] = ComposableTheme(isNineslice, states, variants) + COMPOSABLES[location] = ComposableTheme(isNineslice, states, variants, root.minSize, root.contentPadding) Archie.LOGGER.info( - "Theme \"{}\" loaded ({} states, {} variants, nineslice={})", - location, states.size, variants.size, isNineslice, + "Theme \"{}\" loaded ({} states, {} variants, nineslice={}, minSize={})", + location, states.size, variants.size, isNineslice, root.minSize, ) } catch (e: Exception) { Archie.LOGGER.warn("Error processing theme at {}: {}", location, e.message, e) @@ -262,11 +315,11 @@ class ThemeResourceListener : private fun ResourceManager.isNineSliceTexture(texture: ResourceLocation): Boolean { val candidates = if (texture.path.startsWith("textures/") && texture.path.endsWith(".png")) { - listOf(ResourceLocation.fromNamespaceAndPath(texture.namespace, "${texture.path}.mcmeta")) + listOf(texture + ".mcmeta") } else { listOf( - ResourceLocation.fromNamespaceAndPath(texture.namespace, "textures/gui/sprites/${texture.path}.png.mcmeta"), - ResourceLocation.fromNamespaceAndPath(texture.namespace, "textures/${texture.path}.png.mcmeta"), + "textures" / ("gui" / ("sprites" / texture)) + ".png.mcmeta", + "textures" / texture + ".png.mcmeta", ) } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/Theme.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/Theme.kt index ecc8dd081..5cd91f30f 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/Theme.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/theme/Theme.kt @@ -2,6 +2,7 @@ package net.kernelpanicsoft.archie.gui.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import kotlinx.serialization.SerialName @@ -9,6 +10,7 @@ import kotlinx.serialization.Serializable import net.kernelpanicsoft.archie.Archie import net.kernelpanicsoft.archie.resourcepacks.SerializationReloadListener import net.kernelpanicsoft.archie.serialization.SerializationManager +import net.kernelpanicsoft.archie.gui.layer.LocalLayerManagerOrNull import net.kernelpanicsoft.archie.gui.util.KColor import net.kernelpanicsoft.archie.util.div import net.kernelpanicsoft.archie.util.rem @@ -183,11 +185,27 @@ fun Theme( lightTextColor: KColor = KColor.WHITE, namespace: String = Archie.MOD_ID, content: @Composable () -> Unit, -) = CompositionLocalProvider(LocalTheme provides ThemeData(mode, type, darkTextColor, lightTextColor, namespace)) { content() } +) = Theme(ThemeData(mode, type, darkTextColor, lightTextColor, namespace), content) /** * Sets the active theme using a pre-built [ThemeData]. + * + * Also pushes [data] onto the nearest [net.kernelpanicsoft.archie.gui.layer.LayerStackManager]'s + * theme stack while mounted (popping it back off on dispose), so a modal/dropdown/tooltip pushed + * from anywhere in [content] inherits this theme too instead of silently falling back to + * [LocalTheme]'s own default - each later-pushed layer is its own top-level composition (see + * [net.kernelpanicsoft.archie.gui.layer.LayerStackManager]'s own doc) and would otherwise never + * see a `Theme {}` that only wraps the base layer's content. A nested `Theme {}` override stacks + * on top of whatever it's nested inside for as long as it stays mounted. */ @Composable -fun Theme(data: ThemeData, content: @Composable () -> Unit) = +fun Theme(data: ThemeData, content: @Composable () -> Unit) { + val layers = LocalLayerManagerOrNull.current + if (layers != null) { + DisposableEffect(data) { + layers.themeStack.add(data) + onDispose { layers.themeStack.remove(data) } + } + } CompositionLocalProvider(LocalTheme provides data) { content() } +} diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/FluidStackNBTHolderImpl.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/FluidStackNBTHolderImpl.kt index 39cb03450..8717fa351 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/FluidStackNBTHolderImpl.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/FluidStackNBTHolderImpl.kt @@ -49,19 +49,19 @@ class FluidStackNBTHolderImpl(private val stack: FluidStack) : NBTHolder { loadFromStack() return runCatching { - NBT.decodeFromNbtTagRootless(serializer, data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(serializer, default()) + SerializationManager.nbt.decodeFromNbtTagRootless(serializer, data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(serializer, default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, ret) ret }.getOrThrow() } override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, value) saveToStack() } } @@ -84,19 +84,19 @@ class FluidStackNBTHolderImpl(private val stack: FluidStack) : NBTHolder { loadFromStack() return ObservableList(runCatching { - NBT.decodeFromNbtTagRootless(ListSerializer(serializer), data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(ListSerializer(serializer), default()) + SerializationManager.nbt.decodeFromNbtTagRootless(ListSerializer(serializer), data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), ret) ret }.getOrThrow().toMutableList()) { list -> setValue(thisRef, property, list)} } override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableList) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), value) saveToStack() } } @@ -119,19 +119,19 @@ class FluidStackNBTHolderImpl(private val stack: FluidStack) : NBTHolder { loadFromStack() return ObservableMap(runCatching { - NBT.decodeFromNbtTagRootless(MapSerializer(String.serializer(), serializer), data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), default()) + SerializationManager.nbt.decodeFromNbtTagRootless(MapSerializer(String.serializer(), serializer), data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), ret) ret }.getOrThrow().toMutableMap()) { map -> setValue(thisRef, property, map)} } override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableMap) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) saveToStack() } } @@ -239,6 +239,6 @@ class FluidStackNBTHolderImpl(private val stack: FluidStack) : NBTHolder override fun updateProperty(propertyName: String, serializer: KSerializer, value: T) { - this.data[propertyName] = NBT.encodeToNbtTagRootless(serializer, value) + this.data[propertyName] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, value) } } \ No newline at end of file diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt index e97bfe7b7..3b93ecb25 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt @@ -61,19 +61,19 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder { loadFromStack() return runCatching { - NBT.decodeFromNbtTagRootless(serializer, data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(serializer, default()) + SerializationManager.nbt.decodeFromNbtTagRootless(serializer, data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(serializer, default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, ret) ret }.getOrThrow() } override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, value) if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), serializer, value) saveToStack() @@ -109,19 +109,19 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder { loadFromStack() return ObservableList(runCatching { - NBT.decodeFromNbtTagRootless(ListSerializer(serializer), data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(ListSerializer(serializer), default()) + SerializationManager.nbt.decodeFromNbtTagRootless(ListSerializer(serializer), data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), ret) ret }.getOrThrow().toMutableList()) { list -> setValue(thisRef, property, list) } } override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableList) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), value) if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ListSerializer(serializer), value.toList()) saveToStack() @@ -156,19 +156,19 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder { loadFromStack() return ObservableMap(runCatching { - NBT.decodeFromNbtTagRootless(MapSerializer(String.serializer(), serializer), data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), default()) + SerializationManager.nbt.decodeFromNbtTagRootless(MapSerializer(String.serializer(), serializer), data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), ret) ret }.getOrThrow().toMutableMap()) { map -> setValue(thisRef, property, map) } } override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableMap) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), MapSerializer(String.serializer(), serializer), value.toMap()) saveToStack() @@ -339,7 +339,7 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder } else { - this.data[propertyName] = NBT.encodeToNbtTagRootless(serializer, value) + this.data[propertyName] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, value) } // Unlike NBTHolderImpl's `data` (a BlockEntity's own persisted state), `data` here is only // a transient copy - must be flushed to the stack explicitly or a remote edit is lost. diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/NBTHolderImpl.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/NBTHolderImpl.kt index 5c080277c..f4b730b23 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/NBTHolderImpl.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/NBTHolderImpl.kt @@ -53,19 +53,19 @@ class NBTHolderImpl : NBTHolder override fun getValue(thisRef: Any?, property: KProperty<*>): T { return runCatching { - NBT.decodeFromNbtTagRootless(serializer, data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(serializer, default()) + SerializationManager.nbt.decodeFromNbtTagRootless(serializer, data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(serializer, default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, ret) ret }.getOrThrow() } override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, value) if (thisRef is BlockEntity) { if (property.name.toSnakeCase() in sync) @@ -99,19 +99,19 @@ class NBTHolderImpl : NBTHolder override fun getValue(thisRef: Any?, property: KProperty<*>): MutableList { return ObservableList(runCatching { - NBT.decodeFromNbtTagRootless(ListSerializer(serializer), data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(ListSerializer(serializer), default()) + SerializationManager.nbt.decodeFromNbtTagRootless(ListSerializer(serializer), data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), ret) ret }.getOrThrow().toMutableList()) { list -> setValue(thisRef, property, list) } } override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableList) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(ListSerializer(serializer), value) if (thisRef is BlockEntity) { if (property.name.toSnakeCase() in sync) @@ -145,19 +145,19 @@ class NBTHolderImpl : NBTHolder override fun getValue(thisRef: Any?, property: KProperty<*>): MutableMap { return ObservableMap(runCatching { - NBT.decodeFromNbtTagRootless(MapSerializer(String.serializer(), serializer), data.getOrPut(property.name.toSnakeCase()) { - NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), default()) + SerializationManager.nbt.decodeFromNbtTagRootless(MapSerializer(String.serializer(), serializer), data.getOrPut(property.name.toSnakeCase()) { + SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), default()) }) }.recover { val ret = default() - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), ret) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), ret) ret }.getOrThrow().toMutableMap()) { map -> setValue(thisRef, property, map) } } override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableMap) { - data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) + data[property.name.toSnakeCase()] = SerializationManager.nbt.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) if (thisRef is BlockEntity) { if (property.name.toSnakeCase() in sync) @@ -299,6 +299,6 @@ class NBTHolderImpl : NBTHolder override fun updateProperty(propertyName: String, serializer: KSerializer, value: T) { - this.data[propertyName] = NBT.encodeToNbtTagRootless(serializer, value) + this.data[propertyName] = SerializationManager.nbt.encodeToNbtTagRootless(serializer, value) } } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/SerializationManager.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/SerializationManager.kt index 20771408a..2979b388b 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/SerializationManager.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/SerializationManager.kt @@ -90,6 +90,15 @@ object SerializationManager { serializersModule = sharedModule } + /** + * The [SerializersModule] shared by [cbor]/[json]/[nbt], for code that needs a serializer + * without going through one of those formats - e.g. `module.serializer()` instead of the + * bare top-level `serializer()`, which silently ignores every contextual serializer + * registered here (ResourceLocation, BlockPos, etc. - see [MinecraftSerializersModule]) and + * falls back to raw reflection instead. + */ + val module: SerializersModule get() = sharedModule + /** The shared [Cbor] instance, reconfigured with [sharedModule] whenever [overwriteWith] or [invoke] runs. */ var cbor: Cbor = createCbor() private set diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/Utils.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/Utils.kt index eaa29989f..f9ec2e292 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/Utils.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/Utils.kt @@ -22,6 +22,11 @@ import net.minecraft.network.codec.StreamCodec import kotlin.reflect.KClass import com.google.gson.JsonElement as GsonElement +/** + * Gets data from a [RegistryFriendlyByteBuf] using the [KSerializer] resolved for its type. + */ +inline fun RegistryFriendlyByteBuf.read(): T = read(SerializationManager.module.serializer()) + /** * Gets data from a [RegistryFriendlyByteBuf] using the provided [KSerializer] */ @@ -29,10 +34,9 @@ fun RegistryFriendlyByteBuf.read(serializer: KSerializer): T = SerializationManager.cbor.decodeFromByteArray(serializer, readByteArray()) /** - * Writes data into a [RegistryFriendlyByteBuf] using the [KSerializer] using the class of the data + * Writes data into a [RegistryFriendlyByteBuf] using the [KSerializer] resolved for its type. */ -@OptIn(InternalSerializationApi::class) -fun RegistryFriendlyByteBuf.write(data: T) = write(data::class.serializer() as KSerializer, data) +inline fun RegistryFriendlyByteBuf.write(data: T) = write(SerializationManager.module.serializer(), data) /** * Writes data into a [RegistryFriendlyByteBuf] using a [KSerializer] @@ -92,8 +96,8 @@ val KSerializer.codec: Codec */ val KSerializer.streamCodec: StreamCodec get() = StreamCodec.of( - { buffer, value -> buffer.writeByteArray(SerializationManager.cbor.encodeToByteArray(this, value)) }, - { buffer -> SerializationManager.cbor.decodeFromByteArray(this, buffer.readByteArray()) } + { buffer, value -> buffer.write(this, value) }, + { buffer -> buffer.read(this) } ) /** diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieEnergyStorage.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieEnergyStorage.kt index c7139fd71..a2da30973 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieEnergyStorage.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieEnergyStorage.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.encoding.encodeStructure import net.benwoodworth.knbt.NbtTag -import net.kernelpanicsoft.archie.serialization.NBT +import net.kernelpanicsoft.archie.serialization.SerializationManager import net.kernelpanicsoft.archie.serialization.decodeFromNbtTagRootless import net.kernelpanicsoft.archie.serialization.encodeToNbtTagRootless import kotlin.math.min @@ -96,7 +96,7 @@ class ArchieEnergyStorage( } /** Snapshots this storage's [getCapacity] and stored amount as an [NbtTag], for save/sync. */ - override fun createSnapshot(): NbtTag = NBT.encodeToNbtTagRootless(serializer(), this) + override fun createSnapshot(): NbtTag = SerializationManager.nbt.encodeToNbtTagRootless(serializer(), this) /** * Restores this storage's capacity and stored amount from a snapshot produced by @@ -106,7 +106,7 @@ class ArchieEnergyStorage( */ override fun readSnapshot(snapshot: NbtTag) { - val decoded = NBT.decodeFromNbtTagRootless(serializer(), snapshot) + val decoded = SerializationManager.nbt.decodeFromNbtTagRootless(serializer(), snapshot) this.capacity = decoded.capacity.coerceAtLeast(0) this.amount = decoded.amount.coerceIn(0, this.capacity) } diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidSlot.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidSlot.kt index 501b2d518..15a1ef07c 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidSlot.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidSlot.kt @@ -19,7 +19,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.encoding.encodeStructure import net.benwoodworth.knbt.NbtTag -import net.kernelpanicsoft.archie.serialization.NBT +import net.kernelpanicsoft.archie.serialization.SerializationManager import net.kernelpanicsoft.archie.serialization.decodeFromNbtTagRootless import net.kernelpanicsoft.archie.serialization.encodeToNbtTagRootless import net.kernelpanicsoft.archie.serialization.kSerializer @@ -134,7 +134,7 @@ class ArchieFluidSlot(private val limit: Long, private val onUpdate: () -> Unit override fun createSnapshot(): NbtTag { - return NBT.encodeToNbtTagRootless(serializer(), this) + return SerializationManager.nbt.encodeToNbtTagRootless(serializer(), this) } override fun update() @@ -144,7 +144,7 @@ class ArchieFluidSlot(private val limit: Long, private val onUpdate: () -> Unit override fun readSnapshot(snapshot: NbtTag) { - this.stack = NBT.decodeFromNbtTagRootless(serializer(), snapshot).stack + this.stack = SerializationManager.nbt.decodeFromNbtTagRootless(serializer(), snapshot).stack } /** Serializes an [ArchieFluidSlot] as its [limit] followed by its [ResourceStack] (or `null` when blank). */ diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidStorage.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidStorage.kt index b90779a55..eb8444752 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidStorage.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieFluidStorage.kt @@ -16,7 +16,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.encoding.encodeStructure import net.benwoodworth.knbt.NbtTag -import net.kernelpanicsoft.archie.serialization.NBT +import net.kernelpanicsoft.archie.serialization.SerializationManager import net.kernelpanicsoft.archie.serialization.decodeFromNbtTagRootless import net.kernelpanicsoft.archie.serialization.encodeToNbtTagRootless import net.minecraft.core.NonNullList @@ -68,7 +68,7 @@ open class ArchieFluidStorage private constructor( override fun createSnapshot(): NbtTag { - return NBT.encodeToNbtTagRootless(serializer(), this) + return SerializationManager.nbt.encodeToNbtTagRootless(serializer(), this) } override fun update() @@ -78,7 +78,7 @@ open class ArchieFluidStorage private constructor( override fun readSnapshot(snapshot: NbtTag) { - val slots = NBT.decodeFromNbtTagRootless(serializer(), snapshot).slots + val slots = SerializationManager.nbt.decodeFromNbtTagRootless(serializer(), snapshot).slots for (i in 0 until min(this.slots.size, slots.size)) { this.slots[i] = slots[i] diff --git a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemSlot.kt b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemSlot.kt index 2bf6f01d3..8de73c3d4 100644 --- a/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemSlot.kt +++ b/core/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemSlot.kt @@ -1,6 +1,6 @@ package net.kernelpanicsoft.archie.transfer -import net.kernelpanicsoft.archie.serialization.NBT +import net.kernelpanicsoft.archie.serialization.SerializationManager import net.kernelpanicsoft.archie.serialization.decodeFromNbtTagRootless import net.kernelpanicsoft.archie.serialization.encodeToNbtTagRootless import earth.terrarium.common_storage_lib.resources.ResourceStack @@ -144,7 +144,7 @@ class ArchieItemSlot(private val onUpdate: () -> Unit = {}) : StorageSlot Unit = {}) : StorageSlot