Skip to content

feat: Compose-vanilla focus bridge, theme system overhaul, and bedrock theme - #16

Merged
KP2048 merged 30 commits into
1.21.xfrom
worktree-compose-focus-bridge
Aug 13, 2026
Merged

feat: Compose-vanilla focus bridge, theme system overhaul, and bedrock theme#16
KP2048 merged 30 commits into
1.21.xfrom
worktree-compose-focus-bridge

Conversation

@KP2048

@KP2048 KP2048 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

A large feature branch covering three connected areas of Archie's Compose-for-Minecraft GUI framework:

1. Compose ↔ vanilla focus bridge

  • Bridges Compose focus state into vanilla's GuiEventListener focus graph (Tab navigation, click-to-focus) for buttons and text fields.
  • Adopts a real InteractionSource model mirroring Compose Foundation's shape (press/hover/focus/drag), replacing ad hoc boolean state.
  • Resets vanilla focus when a modal opens/closes; scrolls focused nodes into view; fixes a real bug where MutableInteractionSource's default SUSPEND buffer silently dropped the newest interaction under load (Tab-focus sometimes not visually updating).

2. Theme system

  • New min_size / content_padding theme properties so a composable's intrinsic size/inner spacing is theme-driven instead of hardcoded per component.
  • Switch/Slider now read their track/thumb/fill dimensions from the theme (falling back to the old shared constants), so a theme's own art renders at its native proportions instead of being force-stretched into constants tuned for Java's look.
  • Full dark-mode variant generated for every built-in ("java") component, plus toggle/hover animation polish across checkbox/radio/tab/slider.
  • Fixes a real bug: modals/dropdowns/tooltips are each their own top-level Composition, so they silently ignored the root's Theme{} and always rendered as the framework default. Theme{} now pushes onto a per-screen mount-order stack that every later-pushed layer re-reads, so nested theme overrides and runtime theme switches both propagate correctly. Covered by two new GameTests.

3. Bedrock theme

A full "bedrock" theme built from Mojang's official bedrock-samples assets (light) and a genuine OreUI dark-mode recolor pack (dark) - button, checkbox, radio, switch, slider, tabs, text field, meters, surface. Iterated extensively against real reference screenshots and the actual Bedrock template JSONs to correct asset choices, proportions, and nine-slice geometry (including a couple of real, previously-undetected nine-slice sizing/border bugs in the generator, fixed generically).

Also

  • Fixed several unrelated real bugs found along the way: IntCoordinates/IntSize's packed-Long constructor corrupting x for negative y, Collapsible's separator filling unbounded height, discarded-modifier bugs in Button/Surface sizing, undersized modal action buttons.
  • Routed NBT/serializer lookups through SerializationManager instead of a couple of module-unaware globals (NBT, bare KClass.serializer()) that silently ignored registered contextual serializers.

Verification

Every commit in this branch was verified via a full compile (core-common/fabric/neoforge, gametest-common/neoforge) followed by the live :archie-gametest-neoforge:runGametestClient suite before being pushed.

🤖 Generated with Claude Code

KP2048 and others added 30 commits August 11, 2026 18:31
…ton)

Adds a Modifier.focusable() + LayoutNodeFocusAdapter that wraps a focusable
LayoutNode as a vanilla GuiEventListener leaf, and overrides children() on
ComposeScreen/ComposeContainerScreen to expose all focusable Compose nodes
(scoped to the top layer, matching topNode()'s modal-aware input dispatch)
through it. Screen already implements Tab/Shift-Tab/arrow-key navigation and
ComponentPath-based focus tracking entirely in terms of children() - this one
override is what makes that machinery (and anything else that walks
GuiEventListener, e.g. Controlify's controller navigation) reach Compose
content at all, matching AbstractWidget's nextFocusPath/getRectangle contract
so the built-in tab-order and arrow-key nearest-neighbor search work as-is.

Wires it end-to-end into Button/ButtonCore: Enter/Space activates a
vanilla-focused button (gated on the same focused state the adapter reads/
writes, to avoid double-firing between the broadcast key dispatch and the
adapter's own keyPressed fallback), and a FocusRingModifier draws a visible
ring. Disabled buttons drop the focusable modifier entirely, mirroring
AbstractWidget.active gating disabled widgets out of Tab order.

WidgetState's "hovered" axis is renamed to focused() and now takes
`isHovered || isFocused`: a keyboard/controller-focused widget gets the same
highlight a mouse-hovered one does, rather than a separate axis most themes
would never define. All six callers (Button, Slider, Checkbox, Radio, Switch,
Tab) migrate to the new name; only Button's argument actually changes.
TabContainer's tabs share ButtonCore, so they pick up Tab/arrow navigation
and the focus ring for free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to the previous commit's WidgetState.hovered -> focused rename:
that only changed the Kotlin-side accessor while keeping the underlying
TextureStates string key as "hovered" for lookup compatibility. This commit
finishes the rename for real - TextureStates.HOVERED/CLICKED_AND_HOVERED
become FOCUSED/CLICKED_AND_FOCUSED ("focused"/"clicked_and_focused"), and
every theme JSON's matching state key is renamed to match (button, checkbox,
radio, slider, slider_handle, switch_track, tab_game, tab_menu).

Texture identifiers that already followed a "_hovered" naming pattern are
renamed alongside their state key (checkbox/radio/switch_track's hovered +
clicked_and_hovered art, tab_game/tab_menu's hovered art) via `git mv`, with
their .mcmeta nine-slice companions renamed to match. Texture identifiers
using an unrelated existing convention (button_highlighted, slider_highlighted,
slider_handle_highlighted, tab_game/tab_menu's clicked_and_focused ->
*_selected_highlighted) are left alone - only the JSON state *key* changes
for those, since the texture path itself is a free-form identifier that
never had to match the state name.

Also fixes the two other consumers of the renamed constants that would
otherwise have failed to compile: InputComponentsGameTest's render-state
assertions and ComposeScreenTestContext's hover() doc comment, plus
docs/gametest.md's matching example and prose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Redundant now that focused merges into the same "focused" texture state a
mouse hover uses - the theme's own hovered/focused art already shows the
highlight, so the extra hand-drawn outline was just visual noise on top of
it. Deletes FocusRingModifier entirely (Button was its only caller).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ConfirmDialog, AlertDialog, PromptDialog, and ChoiceDialog are the only four
ModalScope composables in the codebase. ConfirmDialog insets its content
correctly with Modifier.padding(4) on the Surface itself; the other three
shared ModalDialogScaffold, which instead put Modifier.margin(4) on the
inner Column.

Margin only grows the parent to make room for the child - it doesn't shrink
the constraints the child's own content measures against (MarginModifier
has no modifyInnerConstraints override, only modifyPosition). Padding does
shrink those inner constraints, so it's the correct choice for insetting
content within a themed background, and it's what already made ConfirmDialog
look right. Switches ModalDialogScaffold to the same pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gative y

IntCoordinates(x, y) packs both into one Long as `(x.toLong() shl 32) or
y.toLong()`. y.toLong() alone sign-extends a negative y across the entire
upper 32 bits - the same bits x is packed into - so ORing it in unconditionally
overwrites x with all 1-bits (decodes as -1) regardless of x's real value,
any time y is negative. x's own sign never corrupts y, since `shl 32` always
zeroes the low 32 bits it's shifted out of, independent of sign.

This is the root cause of a real, reproducible bug: Scrollable places its
content at (0, -scrollPos) once scrolled - the first negative y most content
in this framework ever sees - so scrolling silently corrupted every scrolled
node's own x-in-Long to -1 while y kept decoding fine, visible as scrolled
content appearing shifted (not just item slots - anything using absoluteCoords
under the scrolled subtree). IntSize has the identical bug for a negative
height, fixed the same way defensively even though sizes aren't normally
negative in practice.

Root-caused via a GameTest DSL diagnostic reading LayoutNode.x/y directly
before/after a simulated scroll (gametest/.../LayoutComponentsGameTest.kt),
run against the client with :archie-gametest-neoforge:runGametestClient -
confirmed x=0 decoded as x=-1 the instant y went negative, with the
child node's identity and modifier list unchanged, ruling out every other
candidate (recomposition timing, RootContainer re-centering, a stray
modifier) before finding the actual bit-packing bug. Kept as a permanent
regression test (testScrollDoesNotCorruptContentX).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ComposeScreen already called Screen.setInitialFocus() every frame, but this
was harmless before the focus bridge since children() was always empty (no
focus target to find). Now that it returns real content, calling it every
frame is actively wrong: vanilla's nextFocusPath, when something is already
focused, advances to the *next* Tab-order candidate rather than re-selecting
the same one - so every frame while the keyboard was the last input type
would auto-cycle focus to the next focusable element, indistinguishable to
vanilla from a real repeated Tab press.

It also never reset when a modal opened: layerManager.top scopes children()
to just the top layer already, but Screen's own getFocused() reference isn't
cleared automatically, so a Tab-focused base-screen button stayed marked
focused (and kept rendering its focused texture) indefinitely once a modal
opened on top of it, since it's no longer in scope for the reset that
setInitialFocus's own nextFocusPath search would otherwise trigger.

Both are fixed together: track the top layer's identity, and only clear
focus + re-run setInitialFocus when it actually changes (a modal opening or
closing), instead of every frame. Ports the same fix to ComposeContainerScreen,
which didn't call setInitialFocus() at all before this.

Verified against a live client via :archie-gametest-neoforge:runGametestClient
(19/19 passing) with a new regression test (testModalOpenResetsBaseScreenFocus)
that Tab-focuses a base-screen button, opens a modal over it via mouse click
(which deliberately does not touch vanilla focus - see the focus bridge
commit), and asserts the base button's focused render state clears.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ontent's

Collapsible's expanded content is measured with maxHeight = Int.MAX_VALUE
deliberately, to capture the content's natural height for the expand/collapse
animation (visibleHeight = measured height * animation progress). The
separator Spacer next to that content used fillMaxHeight(), which fills
whatever maxHeight it's given - here, that unbounded Int.MAX_VALUE, not the
content's actual height, ballooning both the separator and the whole Row's
reported height into the billions of pixels and pushing everything after the
Collapsible far off-screen.

Replaces the plain Row with a small custom Layout that measures the content
first, then constrains the separator to exactly that height - fillMaxHeight()
then correctly fills *that* bounded constraint. Hardens the existing
Collapsible GameTest with a height-sanity assertion (assertAllDescendantsSized
only rejects non-positive sizes, so it never caught this).

Verified against a live client via :archie-gametest-neoforge:runGametestClient
(19/19 passing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…API shape

Introduces Interaction/InteractionSource (PressInteraction, HoverInteraction,
FocusInteraction, DragInteraction; MutableInteractionSource; collectIsXAsState
composables) and rebuilds the input-composable stack on top of it, matching
Compose Foundation's real modifier signatures wherever this framework's
architecture allows:

- Modifier.focusable(enabled, interactionSource) - same shape as Android's,
  emitting FocusInteraction. Can't be backed by a stateful Modifier.Node here
  (this framework's modifiers are plain immutable data), so it's @composable
  and remembers its own focus flag instead - transparent to callers.
- Modifier.hoverable(interactionSource, enabled) - exact signature match, no
  extra callbacks; observe via collectIsHoveredAsState like Android.
- Modifier.pressable(interactionSource, enabled, onPress) - the press half of
  clickable; onPress stays because a press is inherently an action, unlike
  hover which is pure state.
- Modifier.draggable(state, orientation, enabled, interactionSource,
  onDragStarted, onDragStopped) + DraggableState/rememberDraggableState(onDelta) -
  real delta-dispatch shape, not a raw per-event callback.
- Modifier.toggleable/selectable(value/selected, enabled, interactionSource,
  onValueChange/onClick) - combine focusable+hoverable+pressable into one
  modifier applied directly to a widget's own node, the same way Android's do,
  instead of needing Clickable's extra wrapping container.

Clickable itself now builds on focusable+hoverable+pressable rather than raw
onPointerEvent calls, and gained Enter/Space activation (matching Compose
Foundation's own clickable baking in keyboard activation) plus an isFocused
content parameter. Checkbox, Switch, RadioButton, and Tab all migrate off
Clickable onto toggleable/selectable directly - each drops the extra Box
wrapper Clickable required, simplifying their node tree by one level (radio
options are now Row { RadioButton, Text } instead of Row { Box { RadioButton },
Text }). Slider keeps its own press/drag handling (real Compose Foundation's
own Slider doesn't build on plain draggable either, for the same reason: a
click on the track needs to jump to an absolute position, which a pure
delta-dispatch model can't express) but now emits DragInteraction manually
and gained Left/Right arrow-key value nudging while focused.

Fixes a stale test assertion (RadioGroup's Row expected child names
[Box, Text], now correctly [RadioButton, Text] after the wrapper removal).
Verified against a live client via :archie-gametest-neoforge:runGametestClient
(19/19 passing) after two iterations - the removed Box wrapper broke one
existing hierarchy assertion, now fixed to match the simpler tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TextFieldCore now registers with vanilla focus via LocalVanillaScreen
(setFocused/clearFocus) instead of only managing its own local focus
state, so Tab/Shift-Tab navigation and modal focus-reset both work
consistently with text fields the same way they already do for the
other input components.

Adds BringIntoViewParent/LocalBringIntoViewParent: Scrollable exposes
its clip bounds as a bring-into-view target, and focusable() consults
it on focus gain so Tab-focusing a node scrolled out of view scrolls
it back into the viewport (LayoutNodeFocusAdapter.setFocused calls
through to it). Covered by testFocusScrollsIntoView.

Fixes a real bug this surfaced: LocalVanillaScreen (like any local
provided only around ComposeScreen/ComposeContainerScreen.start()'s
base-layer content) never reached a separately pushed modal layer,
since every Layer's Composition is parented directly to the top-level
Recomposer as a sibling, not nested inside the base layer's own
composition - so a text field inside a modal (e.g. PromptDialog)
threw "Screen has not been provided". LayerStackManager now takes the
vanilla Screen and re-provides LocalVanillaScreen per pushed layer,
the same way it already does for LocalLayerDepth.

Full live GameTest suite: 21/21 passing.
…st LocalVanillaScreen

e00c83b's fix special-cased LocalVanillaScreen alone by having
LayerStackManager take the vanilla Screen and re-provide just that one
local per pushed layer. But the underlying problem - a
CompositionLocalProvider wrapping only the base layer's own content
never reaching a separately pushed layer, since every Layer is its own
top-level Composition parented directly to the shared Recomposer as a
sibling, not nested inside another layer's - applies identically to
every other screen-scoped local (LocalScreen, LocalContainerScreen,
LocalContainerMenu, LocalSlotData, LocalBlockEntityState,
LocalItemState, LocalLayerManager), which happened to work only
because nothing pushed as a modal/dropdown/tooltip needed them yet.

LayerStackManager now takes a general `screenLocals` wrapper instead
of a single Screen param, and push() applies it to every layer it
creates - so ComposeScreen/ComposeContainerScreen.start() declare
their full set of screen-wide locals once, and any layer pushed
through this manager (base, modal, or otherwise) sees all of them
automatically, without each call site needing to remember which ones
matter.

Full live GameTest suite: 21/21 passing.
AlertDialog/PromptDialog/ChoiceDialog's action buttons had no size
modifier at all, unlike ConfirmDialog's Modifier.sizeIn(minWidth = 50,
minHeight = 20) - since button textures are nine-slice (no stretch
target implied), Button applies no size floor of its own without an
explicit modifier, so these buttons shrank to fit just their Text
label instead of looking like normal buttons. Gave all of them the
same explicit sizeIn as ConfirmDialog, including ChoiceDialog's
per-choice buttons (previously width-only, no height floor).

Also fixed a real bug surfaced while investigating: Button.kt and
Surface.kt's non-nineslice size-floor branch built its constraint via
modifier.apply { sizeIn(...) }, but apply() returns the unmodified
receiver - the computed SizeModifier was silently discarded every
time. Doesn't change anything for the currently-nineslice button/
surface textures, but was completely dead for any theme that isn't
nineslice. Rewritten to actually chain the result.

Full live GameTest suite: 21/21 passing.
…size

Themes can now declare "min_size": { "width": ..., "height": ... } at
the root, exposed as ComposableTheme.minSize, so a composable can have
a sensible default minimum size independent of whether its texture is
nine-slice - previously only a non-nine-slice sprite's own dimensions
ever acted as an implicit floor, so a nine-slice component (like
button, which can stretch to any size) had no intrinsic minimum at
all unless every caller remembered to pass one explicitly.

Extracted the shared logic as ComposableTheme.intrinsicSizeModifier():
minSize when the theme declares one, else the old non-nine-slice
sprite-size fallback, else no floor - and wired it into every
composable that previously duplicated (or, in Button/Surface's case,
had a broken copy of) this pattern: Button, Surface, Checkbox,
RadioButton, Tab.

button.json now declares min_size: 50x20, matching what ConfirmDialog
was previously hardcoding per-button via Modifier.sizeIn - removed
that now-redundant boilerplate from ConfirmDialog and DialogPrimitives
(AlertDialog/PromptDialog/ChoiceDialog), which get the same floor
automatically now.

Full live GameTest suite: 21/21 passing.
…rmDialog into DialogPrimitives

Button's label text rendered flush against the button's edges once it
grew past its min_size floor to fit a longer label - nothing reserved
inner spacing around content. Added a content_padding theme property
(ThemePadding: horizontal/vertical), symmetric to min_size, exposed
via ComposableTheme.contentPaddingModifier() and wired into Button;
button.json declares 4x2.

Fixed dialog action rows rendering pinned to the Column's left edge
instead of spread across the dialog - Column resets minWidth to 0
before measuring each child, so a Modifier.fillMaxWidth() on the
action row (the first fix attempted) would have expanded it toward
the screen's own incoming max width rather than the dialog's, the
same unbounded-fill mistake as the earlier Collapsible bug. The real
cause is Column placing children via horizontalAlignment.align(...)
against its own resolved width, defaulting to Alignment.Start - fixed
by centering ModalDialogScaffold's and ConfirmDialog's Column instead,
which carries no such risk since it only repositions, never resizes.

Folded ConfirmDialog into DialogPrimitives.kt as a proper
ModalDialogScaffold-based primitive, alongside AlertDialog/
PromptDialog/ChoiceDialog. It previously reimplemented its own
entered/closing/offsetY animation state, entirely redundant with what
modal()/ModalLayout already drives generically for every dialog via
the shared transitionProgress - worse, ConfirmDialog's own .offset()
compounded on top of ModalLayout's own offset, and its manual delay
before calling dismiss() meant closing played two animations back to
back instead of one shared fade+slide.

Full live GameTest suite: 21/21 passing.
Dark mode: generated archie_themes/java/dark/<name>.json for every
themed composable (button, checkbox, radio, slider, slider_handle,
switch_track, switch_thumb, tab_game, tab_menu, text_field, slot,
energy_bar, fluid_tank, progress_bar, small_checkbox), following the
file-based dark-variant convention already established by the
existing dark/surface.json (ThemeData.getComposableTheme resolves
"java/dark/<composable>" first when Theme(mode = ThemeVariants.DARK)
is active). Each dark JSON is the light one with every texture
reference rewritten to a "_dark" suffixed variant, min_size/
content_padding preserved unchanged.

The 41 "_dark" textures are generated programmatically - a uniform
0.4x RGB multiply (alpha untouched) on each source PNG, preserving
every bevel/border/antialiasing pattern since it's a linear scale, not
a hue/levels remap. .mcmeta nine-slice companions are copied verbatim
alongside their recolored PNG (geometry is unaffected by a color-only
change). Added testDarkThemeResolvesRecoloredButtonTexture as a
regression check that the dark variant actually resolves to the
recolored asset rather than silently falling back to the light theme
on a naming mismatch.

Animations: added a reusable animatePulse() primitive (Animation.kt) -
sweeps from a start value back to a target once per key change, for a
one-shot Easings.OutBack overshoot-then-settle "pop" rather than
animateFloat/animateInt's continuous track-toward-a-moving-target
model. Wired into Checkbox and RadioButton so toggling/selecting pops
instead of snapping instantly. Tab's selected-elevation offset now
eases via animateInt instead of jumping, matching Button's existing
press-offset idiom. Slider's thumb now grows on hover/drag via
animateFloat instead of staying a fixed size regardless of interaction
(Switch's thumb-slide animation already existed - no changes needed
there).

Full live GameTest suite: 22/22 passing.
…sets

Replaces the earlier hand-spliced-from-a-CurseForge-pack version with
one built primarily from Mojang/bedrock-samples' own
resource_pack/textures/ui assets (used under the Minecraft EULA):
classic-button/-hover/-pressed + disabledButton for button,
checkboxUnFilled/checkbox_filled(+composited checkmark) for checkbox,
radio_off/_on/_checked_hover for radio, common-classic_toggle_*_state
for switch_track, slider_background/slider_button_default for
slider/slider_handle, TabTopFront/Back for tab_menu, and
background_panel for surface - each with its real nine-slice geometry
translated straight from Mojang's own nineslice_size/base_size JSON
sidecar into Archie's mcmeta format, rather than guessed border
values.

tab_game and slot are kept from the earlier CurseForge-pack splice
(the user's own local files, cropped from creative_inventory/tabs.png
and container/generic_54.png) since bedrock-samples' own pieces don't
fit their specific shapes as well and these were already
independently verified.

Also gave button a real "clicked" state (classic-button-pressed),
something the java theme's button never had - purely additive, no
engine changes required.

Full live GameTest suite: 23/23 passing.
… mcmeta

text_field previously reused slider_background's recessed look since
nothing dedicated seemed to exist - bedrock-samples actually has
text_edit_base.png/text_edit_hover.png, a real sunken text-input tile.
No nineslice json ships alongside it, but it still needs to stretch to
arbitrary field widths (matching what java's own text_field.json
required), so it's declared nine-slice anyway with a conservative
border derived from its visible banding, rather than left
non-nineslice - a non-nineslice sprite renders at its own tiny native
size regardless of the caller's node size.

Also fixed a real bug this surfaced while auditing the pattern: the
energy_bar/progress_bar/fluid_tank mcmeta files declared the desired
render size (32x16 etc.) instead of slider_background.png's actual
3x3 source dimensions, which would have broken nine-slice UV mapping.
mcmeta must describe the source image; the render size belongs only
in the theme JSON's own width/height, which blitSprite scales the
nine-slice source to independently.

Full live GameTest suite: 23/23 passing.
…lates

button: "classic-button" turned out to be the old/legacy style.
ui_template_buttons.json's own dark_text_button/light_text_button
definitions reference button_borderless_dark/light (+hover/pressed)
instead - switched to those, and gave the light and dark theme
variants their own distinct button set (not just a recolored surface)
since the real templates pair each with its own hover/pressed states.

checkbox/radio: ui_common.json's own "checkbox"/"radio_toggle" node
definitions reveal both share the exact same unchecked-state art
(checkbox_space/checkbox_spaceHover) and differ only in their checked
state - checkbox_check (checkbox) vs checkbox_filled (radio_toggle).
Previously had this backwards: checkbox was using checkbox_filled/
checkboxUnFilled, and radio was using radio_off/radio_on/
radio_checked_hover, none of which ui_common.json's actual checkbox/
radio_toggle definitions ever reference.

switch: found the real toggle_off/toggle_on(+hover) asset pair - a
proper thumb+track graphic with "O"/"I" labels, replacing the more
abstract common-classic_toggle_*_state used before. Split into
separate track and thumb crops since Archie always composites those
as two independent textures.

text_field: wired the real text_edit_base/hover asset (a proper
sunken input tile) instead of reusing the slider background.

Also fixed two invalid nine-slice configs surfaced by NeoForge's
sprite loader actually parsing these at boot (a class of bug none of
the earlier verification could have caught without a real client
run): disabledButton(NoBorder).json's own nineslice_size doesn't fit
its tiny base_size in either case (2*border >= size, and border=0
isn't accepted either - "Value must be positive") - declared
non-nineslice instead, resized up to a fixed reasonable size. Fixed
an unrelated meter-frame bug too: their mcmeta declared the desired
render size instead of the actual 3x3 source dimensions, which would
have broken nine-slice UV mapping.

Full live GameTest suite: 23/23 passing, zero sprite metadata parse
errors (previously 4).
… under load

flow = MutableSharedFlow<Interaction>(extraBufferCapacity = 16) used
the default SUSPEND overflow strategy - fine for a suspending emit(),
but tryEmit() can't suspend, so once the buffer fills, tryEmit()
simply fails and the interaction is dropped. For a state-defining
interaction like FocusInteraction.Focus/Unfocus (collectIsFocusedAsState
only knows what it actually received), dropping the *newest* one under
contention (e.g. rapid Tab presses outrunning the Recomposer's own
dispatch) means the visual focus state silently stops updating -
matches a reported "tabbing through inputs sometimes doesn't trigger
the texture state" symptom exactly.

Switched to BufferOverflow.DROP_OLDEST: tryEmit() now never fails, and
only a stale already-superseded buffered event is ever discarded -
harmless, since collectAsState's reducer only cares about the latest
value of each interaction type anyway.

Full live GameTest suite: 23/23 passing.
…surface asset

surface: background_panel turned out to be unreferenced anywhere in
ui_common.json/ui_template_buttons.json - dialog_background_opaque is
ui_common.json's own actual default $dialog_background. Switched to
that (the same mistake pattern as the earlier button/checkbox mixups:
picking an asset by its name alone instead of verifying it against a
real template reference).

dark theme variant: previously just copied the light variant's own
JSON/textures verbatim (matching how the original CurseForge reference
pack's own light/dark split turned out to mostly just be a surface
recolor). Rebuilt using the user's locally-owned "OreUIDarkM" dark-mode
pack instead - confirmed via minecraft.wiki's own Ore UI documentation
(which points back at the same resource_pack/textures/ui/ directory)
and the pack's own file layout (exclusively targets that same
directory) that this is genuinely Ore UI's own asset set, not a
different/legacy one. Every component that has a dark-pack recolor
(button, checkbox, radio, switch, slider, slider_handle, tab_menu,
surface) now uses it for the dark theme variant, falling back to the
light art only for the few files the pack doesn't include
(checkbox_filled's radio-checked texture, text_edit_base/hover).

Full live GameTest suite: 23/23 passing, zero sprite metadata parse
errors.
The dark theme variant already resolved to the same pixels either way
(background_panel.png and dialog_background_opaque.png are byte-
identical in the OreUIDarkM pack), but the light/stock versions of
the two differ - switched per direct confirmation this is the
intended panel asset. Its bevel (a 2px white highlight band under the
top border, flat fill, a 2px darker shadow band above the bottom
border) sits entirely within the nine-slice's fixed 4px edges, so the
characteristic Bedrock "slight 3D look at the bottom" is preserved
at any panel size rather than stretched away.

Full live GameTest suite: 23/23 passing.
…rface

Same bevel convention as background_panel (top highlight band, flat
fill, bottom shadow band within the fixed nine-slice edges), custom-
built to fit the theme rather than sourced from a reference pack.
Dark variant is unaffected (still background_panel).

Full live GameTest suite: 23/23 passing.
switch_track/switch_thumb are declared non-nineslice, and a
non-nineslice sprite always renders at its own declared width/height
regardless of what the caller (SwitchCore, enforcing
SWITCH_MIN_WIDTH/HEIGHT = 34x18 and SWITCH_THUMB_SIZE = 14) actually
requests. The original v3 script resized its toggle_off/on crops to
match those exact sizes before saving; that resize step was dropped
when v4 restructured the crop logic into off_pieces()/on_pieces()
helpers for the light/dark variant loop. The result, confirmed via a
real in-game screenshot: the track texture (native ~17x12) only
covered part of the switch's real ~34x18 area, exposing the dialog
panel behind it through the gap - looking like a half light/half dark
switch with the thumb stuck mid-track instead of a normal-looking
toggle.

Restored the resize (nearest-neighbor, keeps the flat pixel-art look)
in both off_pieces()/on_pieces().

Also includes test/TestScreen.kt's own Theme(type = "bedrock") switch
(made directly by the user to screenshot-verify this fix), keeping
the showcase screen previewing the bedrock theme.

Full live GameTest suite: 23/23 passing.
…riant nine-slice mismatches

Switch:
- Replace the toggle_off/on splice with the user's own GIMP-extracted
  switch_track.png/switch_thumb(_dark).png. The track is a single static
  bicolor pill (green "I" side, gray ring side) shared by both themes and
  every track state - Switch.kt's own thumb-offset animation slides the
  thumb over whichever side doesn't match the current value, covering it,
  so there's nothing left to crop into separate on/off textures. Only the
  thumb differs between light/dark.
- Delete the now-orphaned switch_track_clicked(_dark)/
  switch_track_clicked_and_focused(_dark).png left behind by the old split.

Slider fill:
- Slider.kt drew its progress fill as a hardcoded solid-color fill() call.
  Replaced with a themed "slider_fill" composable (default/disabled states,
  looked up and drawn via drawThemeState like the track/thumb already are).
- bedrock's slider_fill is Bedrock's own real slider_progress.png nine-slice
  asset; java's is a hand-authored 3x3 nine-slice solid preserving the
  previous hardcoded colors so appearance doesn't change for that theme.

Dark-variant nine-slice mismatches (real bug, not slider_fill-specific):
mcmeta_from_bedrock_json always read width/height/border from the light-only
bedrock-samples json, but OreUIDarkM's dark-mode assets are sometimes
exported at a different native resolution than their light counterpart
(e.g. slider_button_default: 6x6 light vs 9x9 dark; button_borderless_dark:
4x4 vs 9x9). A mcmeta declaring the wrong source size produces bad
nine-slice UVs without necessarily throwing at load - silent visual
corruption, not a crash - which is why it wasn't caught earlier. Replaced
with mcmeta_for_image(), which derives width/height from the actual saved
image, preferring a same-named SAMPLES_DARK json sidecar when the user has
provided one (real per-variant border), otherwise scaling the light
json's border proportionally to the image's real size. Regenerated every
affected mcmeta (button, slider, slider_handle, tab_menu, surface).

Also delete four pre-existing orphaned java tab_*_clicked(.mcmeta) sprites
- java's tab theme actually uses "_selected"/"_selected_highlighted" naming,
so these were dead weight from before this session.

Verified via full compile (core-common/fabric/neoforge, gametest-common/
neoforge) and the live :archie-gametest-neoforge:runGametestClient suite,
23/23 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
These three operator fun plus overloads (a no-separator counterpart to the
existing div operators above them) were added earlier but left undocumented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-theme sizing

Switch/Slider now let a theme override the fixed pixel sizes Switch.kt/
Slider.kt previously hardcoded for every theme:
- ComposableTheme.minSize (already used by Button for its clickable area)
  is now also read by Switch.kt for the track/thumb size, and by Slider.kt
  for the overall size/thumb size - falling back to the old constants when
  a theme doesn't declare one.
- ComposableTheme.contentPadding.horizontal now also controls how far a
  Switch's thumb sits from the track's edge (was a hardcoded 2px gap).

Java is unaffected (declares neither, so keeps the old defaults exactly).
Bedrock now declares:
- switch_track/switch_thumb: min_size matching the user's GIMP-extracted
  art at its own native pixel size (28x13/15x15), used unresized -
  previously these were force-resized into Java's proportions (34x18/
  14x14), visibly distorting a source that was already correctly sized.
  content_padding: {horizontal: 0} so the thumb sits flush in the track's
  corner, matching how the source art was designed to be read.
- slider_handle: min_size 12x16, much closer to the source knob's own
  compact/near-square aspect than Slider.kt's default SLIDER_THUMB_WIDTH/
  HEIGHT (8x20, a tall thin pill tuned for Java's own vanilla-style art),
  which was stretching Bedrock's compact nine-slice knob into a visibly
  distorted shape.

Also fixes a real dark-theme button rendering bug (visible horizontal
banding across the button face): OreUIDarkM's 9x9 button recolor has a 1px
top bevel, a flat face, and a bottom edge that's a 1px bevel for the
resting/hover art but a 1px bevel + 2px drop-shadow for those same states
(vs. a flush 1px bevel with no shadow for the pressed art). The generic
proportionally-scaled border (derived from the light 4x4 source, which has
no such shadow band) swallowed part of the flat face into the tiled bottom
edge, so that discontinuity repeated as visible banding once stretched
across a real button's height. Declared an explicit per-edge border for
this specific asset instead of relying on the generic size-mismatch
heuristic.

TestScreen.kt: mode = "dark" added (user's own testing change, previewing
bedrock's dark variant).

Verified via full compile (core-common/fabric/neoforge, gametest-common/
neoforge) and the live :archie-gametest-neoforge:runGametestClient suite,
23/23 passing (one run hit 4 failures, all the same pre-existing "stale
render state" harness race documented in engram - unrelated composables,
no Kotlin interaction-timing code touched here, and a clean rerun
immediately after confirmed it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…al Bedrock UI

Measured pixel-for-pixel against a real Bedrock settings screenshot: the
fill isn't a thin accent line over a much taller track like Java's own
vanilla-style slider - it's a full-thickness bicolor bar (filled portion
one shade, unfilled another), both the exact same thickness as the track.
Slider.kt's SLIDER_TRACK_HEIGHT=2 fill height (shared by every theme) was
producing a thin sliver instead.

Slider.kt now reads "slider_fill"'s own min_size.height, falling back to
SLIDER_TRACK_HEIGHT so Java's look is unchanged. Bedrock declares 14 (close
to the slider_handle thumb's own 16, leaving it slightly overhanging the
fill/track exactly as in the reference screenshot).

Verified via full compile and the live runGametestClient suite, 23/23.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…finement

The slider track was still rendering at the full SLIDER_MIN_HEIGHT=20
container height, leaving a too-wide 3px gap around the 14px-tall fill.
Gives "slider" its own min_size (96x16, 1px margin on each side of the
fill) instead of falling back to the shared default.

Also pulls in the user's small switch_track.png refinement (a 1px
boundary shift between the green/gray halves).

Verified via full compile and the live runGametestClient suite, 23/23.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… 0%/100%

trackStart/trackEnd inset by half the thumb's width on both ends, meant to
keep the thumb's center within the track - but resolveSliderThumbX already
independently clamps the thumb's own position into [x, x+width-thumbWidth],
so insetting the fill's bounds too doubled up the margin, leaving a gap the
fill could never close even at 100% (visibly: "the fill doesn't touch the
left edge"). trackStart/trackEnd now span the track's real full width;
the thumb still can't run past the track edges since its own clamp is
unaffected by this change.

Verified via full compile and the live runGametestClient suite, 23/23.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…module-unaware globals

NBTHolderImpl and its item/fluid-stack counterparts, plus every
ArchieItemStorage/ArchieFluidStorage/ArchieEnergyStorage/ArchieItemSlot/
ArchieFluidSlot snapshot, all used the standalone NBT.kt global (a bare
Nbt {} instance with no serializersModule) instead of
SerializationManager.nbt (the one actually kept in sync with
SerializationManager's shared module - registered contextual serializers,
anything a mod registers via SerializationManager.overwriteWith, etc.).
Swapped every one of those 8 files over to SerializationManager.nbt.

Same root problem, different shape, in a few serializer() lookups:
- BlockEntityStateComposables.observeProperty/ItemStateComposables.
  observeItemProperty called the bare top-level serializer<T>(), which
  resolves against EmptySerializersModule and ignores the shared module
  entirely.
- FieldType's EnumSelector/Selector (Cloth Config enum/selector fields)
  and RegistryFriendlyByteBuf.write() called KClass.serializer()/
  data::class.serializer(), kotlinx.serialization's raw-reflection lookup -
  same problem, different API shape.

Exposed SerializationManager.module (the shared SerializersModule) so
this class of lookup can be done properly: module.serializer<T>() for the
two reified call sites, module.serializer(kClass.createType()) for the two
non-reified ones. RegistryFriendlyByteBuf.write() itself is now inline
reified, matching a companion .read() added alongside it, so both go
through the same module.serializer<T>() path without reflection.

Verified via full compile and the live runGametestClient suite, 23/23.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each layer LayerStackManager pushes (modal, dropdown, tooltip) is its own
top-level Composition parented directly to the screen's Recomposer, not
nested under the base layer's tree - so LocalTheme.current inside a modal
silently fell back to its own default ("java") instead of whatever
Theme {} the base layer actually used, regardless of the screen's real
theme.

Theme {} now pushes its ThemeData onto LayerStackManager.themeStack (a
mount-order stack, popped on dispose) whenever a LayerStackManager is in
scope, via a new nullable-safe LocalLayerManagerOrNull local. Each screen's
screenLocals wrapping (ComposeScreen/ComposeContainerScreen) re-supplies
LocalTheme from the stack's top (LayerStackManager.rootTheme) to every
layer it creates, so:
- A plain root Theme {} now reaches every modal/dropdown/tooltip.
- A nested Theme {} override sits on top of whatever it's nested inside
  while mounted, so a modal triggered from within it inherits the
  override, not the outer root - and reverts once the override unmounts.
- A theme flipped at runtime propagates immediately (the stack is backed
  by mutableStateListOf).

Added testModalInheritsRootTheme and testModalInheritsNestedThemeOverride
to ModalComponentsGameTest, covering both cases directly (a modal reading
LocalTheme.current, captured via SideEffect). Also folds Utils.kt's
streamCodec through the read/write extensions added last commit instead
of duplicating their cbor calls inline.

Verified via full compile and the live runGametestClient suite, 25/25
(23 previous + 2 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 13, 2026 03:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@KP2048 KP2048 changed the title Compose-vanilla focus bridge, theme system overhaul, and bedrock theme feat: Compose-vanilla focus bridge, theme system overhaul, and bedrock theme Aug 13, 2026
@KP2048
KP2048 merged commit f1a9740 into 1.21.x Aug 13, 2026
3 of 7 checks passed
@KP2048
KP2048 deleted the worktree-compose-focus-bridge branch August 13, 2026 03:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants