Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a221931
Bridge Compose focus into vanilla's GuiEventListener focus graph (But…
KP2048 Aug 11, 2026
916f8f5
Rename hovered/HOVERED texture state to focused/FOCUSED throughout
KP2048 Aug 11, 2026
981ac62
Remove the focus-ring overlay from ButtonCore
KP2048 Aug 11, 2026
b121610
Fix modal padding: use Surface padding, not inner-Column margin
KP2048 Aug 11, 2026
088ca34
Fix IntCoordinates/IntSize packed-Long constructor corrupting x on ne…
KP2048 Aug 11, 2026
e25f3cd
Reset vanilla focus when a modal opens or closes
KP2048 Aug 12, 2026
90a6ee0
Fix Collapsible's separator bar filling unbounded height instead of c…
KP2048 Aug 12, 2026
d4ce012
Adopt a real InteractionSource model, mirroring Compose Foundation's …
KP2048 Aug 12, 2026
e00c83b
Bridge TextField focus to vanilla, scroll focused nodes into view
KP2048 Aug 12, 2026
e4a6df1
Share all screen-scoped composition locals across every layer, not ju…
KP2048 Aug 12, 2026
f94de16
Fix undersized modal action buttons and a discarded-modifier bug
KP2048 Aug 12, 2026
9013eca
Add an explicit min_size theme property for a composable's intrinsic …
KP2048 Aug 12, 2026
5bbd4eb
Add content_padding theme property, center dialog actions, fold Confi…
KP2048 Aug 12, 2026
e479485
Add dark theme variant for all components and toggle/hover animations
KP2048 Aug 12, 2026
64e63cf
Rebuild the bedrock theme around Mojang's official bedrock-samples as…
KP2048 Aug 12, 2026
d6a8e8b
Wire real text_edit_base/hover assets for text_field, fix meter-frame…
KP2048 Aug 12, 2026
1625dc7
Correct button/checkbox/radio bedrock assets against verified UI temp…
KP2048 Aug 12, 2026
84783ec
Fix MutableInteractionSource silently dropping the newest interaction…
KP2048 Aug 12, 2026
d84c73b
Use genuine OreUI dark-mode recolors for the bedrock/dark theme, fix …
KP2048 Aug 12, 2026
c431a24
Switch surface's light variant to background_panel
KP2048 Aug 13, 2026
24283e7
Use the user's hand-edited panel.png for the bedrock light theme's su…
KP2048 Aug 13, 2026
f749f40
Fix switch track/thumb rendering far too small (v4 rewrite regression)
KP2048 Aug 13, 2026
b7d7a6d
Rework bedrock switch/slider art, add themed slider fill, fix dark-va…
KP2048 Aug 13, 2026
236ac24
Document the ResourceLocation plus operators
KP2048 Aug 13, 2026
3ef0cfc
Fix bedrock switch/slider proportions and dark button banding via per…
KP2048 Aug 13, 2026
1c3d2ad
Draw the slider fill at full track thickness for bedrock, matching re…
KP2048 Aug 13, 2026
537658f
Tighten bedrock slider track height and pull in switch_track pixel re…
KP2048 Aug 13, 2026
2749831
Fix slider fill leaving a permanent gap at both edges, never reaching…
KP2048 Aug 13, 2026
aabf099
Route NBT/serializer lookups through SerializationManager instead of …
KP2048 Aug 13, 2026
f77c768
Fix modals not inheriting the root Theme, add regression coverage
KP2048 Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package net.kernelpanicsoft.archie.config

import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
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,
Expand Down Expand Up @@ -81,14 +82,16 @@ internal sealed class FieldType<T>

data class EnumSelector<T : Enum<T>>(val kClass: KClass<T>) : FieldType<T>()
{
@OptIn(InternalSerializationApi::class)
override val serializer: KSerializer<T> = 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<T> = SerializationManager.module.serializer(kClass.createType()) as KSerializer<T>
}

data class Selector<T : Any>(val kClass: KClass<T>) : FieldType<T>()
{
@OptIn(InternalSerializationApi::class)
override val serializer: KSerializer<T> = kClass.serializer()
@Suppress("UNCHECKED_CAST")
override val serializer: KSerializer<T> = SerializationManager.module.serializer(kClass.createType()) as KSerializer<T>
}

data object IntList : FieldType<List<kotlin.Int>>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,6 +118,9 @@ abstract class ComposeContainerScreen<T : ComposeContainerMenuBase<T>>(
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

Expand Down Expand Up @@ -141,14 +149,13 @@ abstract class ComposeContainerScreen<T : ComposeContainerMenuBase<T>>(
*/
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 /
Expand All @@ -158,12 +165,29 @@ abstract class ComposeContainerScreen<T : ComposeContainerMenuBase<T>>(
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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -247,6 +271,14 @@ abstract class ComposeContainerScreen<T : ComposeContainerMenuBase<T>>(
{
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(
Expand Down Expand Up @@ -376,6 +408,14 @@ abstract class ComposeContainerScreen<T : ComposeContainerMenuBase<T>>(

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<GuiEventListener> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -26,6 +31,21 @@ import kotlin.coroutines.CoroutineContext
val LocalScreen: ProvidableCompositionLocal<ComposeScreen> =
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<Screen> =
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
Expand Down Expand Up @@ -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/
Expand All @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<GuiEventListener> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}


Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -40,5 +41,5 @@ inline fun <reified T> observeProperty(
initialValue: T? = null,
): MutableState<T?> {
val state = LocalBlockEntityState.current ?: throw RuntimeException("No block entity state available in composition")
return state.observeProperty<T>(propertyName, serializer(), initialValue)
return state.observeProperty<T>(propertyName, SerializationManager.module.serializer(), initialValue)
}
Loading
Loading