feat: Compose-vanilla focus bridge, theme system overhaul, and bedrock theme - #16
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A large feature branch covering three connected areas of Archie's Compose-for-Minecraft GUI framework:
1. Compose ↔ vanilla focus bridge
GuiEventListenerfocus graph (Tab navigation, click-to-focus) for buttons and text fields.InteractionSourcemodel mirroring Compose Foundation's shape (press/hover/focus/drag), replacing ad hoc boolean state.MutableInteractionSource's defaultSUSPENDbuffer silently dropped the newest interaction under load (Tab-focus sometimes not visually updating).2. Theme system
min_size/content_paddingtheme properties so a composable's intrinsic size/inner spacing is theme-driven instead of hardcoded per component.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-samplesassets (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
IntCoordinates/IntSize's packed-Longconstructor corruptingxfor negativey,Collapsible's separator filling unbounded height, discarded-modifier bugs inButton/Surfacesizing, undersized modal action buttons.SerializationManagerinstead of a couple of module-unaware globals (NBT, bareKClass.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:runGametestClientsuite before being pushed.🤖 Generated with Claude Code