Skip to content

Concepts

“Perrier” edited this page May 10, 2026 · 1 revision

Concepts

This page covers the core abstractions that everything else builds on: the component lifecycle, reactive state, focus management, and the measure / layout pipeline.

UIComponent

UIComponent is the abstract base class for every visual element. Each component owns:

  • a Style (immutable styling configuration)
  • a position and size — int x, int y, int width, int height
  • two layout-invalidation flags — needsMeasure, needsLayout
  • subscriptions registered via track(...), all auto-cleaned on detach

Lifecycle

The UIScreen adapter calls these in order. Override what you need; ignore the rest.

Method When Purpose
onAttach(UIContext ctx) When the screen opens (or after a resize re-init) Register listeners, store the UIContext, propagate to children. Idempotent — safe to call twice.
measure(int maxW, int maxH) Layout phase Return a MeasureResult(width, height) — the desired size given the available space.
layout(int x, int y, int w, int h) Layout phase Receive the final position and size. Position children if you're a container.
render(DrawContext ctx) Every frame Draw using DrawContext.
onDetach() When the screen closes Unsubscribe everything. The default implementation already disposes anything passed to track(...).

Event hooks

All return boolean (consume = true); void ones are observation-only.

boolean onMouseClick(double x, double y, int button);
boolean onMouseScroll(double x, double y, double scrollDelta);
void    onMouseMove(double x, double y);
boolean onMouseDrag(double x, double y, double dragX, double dragY, int button);
boolean onMouseRelease(double x, double y, int button);

boolean onKeyPress(int keyCode, int scanCode, int modifiers);  // focused only
boolean onCharTyped(char chr, int modifiers);                  // focused only

void onFocus();
void onBlur();

isPointInside(double, double) is a helper for hit-tests.

UIContext

One UIContext exists per UIScreen. It carries shared services into the component tree.

Focus

ctx.requestFocus(component);   // transfer focus, fires onBlur/onFocus
ctx.getFocused();              // current focused component, or null
ctx.clearFocus();              // blur whoever's focused

The screen routes onKeyPress and onCharTyped to the focused component only. Clicking outside the focused component blurs it (handled by UIScreen).

Overlay queue

For things that need to draw above their nominal position in the tree (tooltips, popups), a component can call:

ctx.deferOverlay(drawContext -> {
    // arbitrary draw calls executed after the main tree
});

The UIScreen flushes the queue once per frame after the tree finishes rendering.

Popup click handlers

Open popovers (e.g. a ComboBox dropdown) can intercept clicks before the tree dispatches them, so a click on the popover itself is consumed even when the popover extends outside its parent's clip:

Runnable unregister = ctx.registerPopupClickHandler(
    (x, y, button) -> /* return true to consume */ false
);
// ...later
unregister.run();

Handlers are dispatched LIFO. The ComboBox and ColorPicker components use this internally.

State<T>

Observable mutable value, the cornerstone of the reactive model. Calls to set(...) notify every registered listener — but only if the new value isn't Objects.equals to the current one (which is what makes bidirectional bindings safe from feedback loops).

Creating

State<Integer> a = State.of(0);
State<Integer> b = State.of(0, "demo.counter");  // named — appears in debug overlay

Named states live in a global weak-valued registry. Re-registering the same name replaces the previous entry; the registry doesn't keep states alive past their natural lifetime.

Reading and writing

int v = a.get();          // current value
a.set(v + 1);             // notifies listeners; no-op if equal to current

set is thread-safe: when called off the render thread, the update is forwarded onto it via MinecraftClient.execute. Listener callbacks therefore always run on the render thread.

Subscribing

Subscription sub = a.onChange(v -> System.out.println("now " + v));
sub.unsubscribe();   // stop receiving notifications

Pass the Subscription to component.track(sub) and it'll be unsubscribed automatically when the component detaches.

Derivation

State<Integer>  count    = State.of(0);
State<String>   label    = count.map(v -> "Items: " + v);
State<Boolean>  empty    = count.map(v -> v == 0);

State<String>   summary  = State.combine(name, count,
                                         (n, c) -> n + ": " + c + " items");

map and combine install upstream subscriptions automatically. Calling dispose() on a derived state cancels them so the source doesn't keep a permanent reference.

Bidirectional binding

Subscription bind = State.bindBidirectional(stateA, stateB);
// stateB is set to stateA's current value first.
// Subsequent changes on either side propagate to the other.
bind.unsubscribe();   // breaks the link in both directions

The equals-check in set prevents the round-trip from looping forever.

Disposal

state.dispose();      // unsubscribe upstream, clear listeners, drop registry entry
state.isDisposed();

Idempotent. Root states (created with of) just clear their listeners.

Subscription

Functional interface returned by anything that registers a callback:

@FunctionalInterface
public interface Subscription { void unsubscribe(); }

The convention: pass it to component.track(...) so the lifecycle handles cleanup, or hold it yourself if you need finer control.

Measure & layout flow

Layout is a two-phase walk over the tree:

  1. Measure — every component returns a MeasureResult(width, height) given the maximum space it can occupy. Containers measure children first, then size themselves.
  2. Layout — the parent assigns each child a final (x, y, width, height). The component stores those values and renders against them.

Layout runs on attach and on screen resize; component-level invalidation flags (needsMeasure, needsLayout) request a rerun on the next frame.

Size sentinels

Style.width(...) and Style.height(...) accept three flavors of value, distinguished by sign:

Sentinel Value Meaning
Size.WRAP_CONTENT -1 Fit to intrinsic size, clamped to available
Size.MATCH_PARENT -2 Use all available space from parent
Fixed > 0 Explicit pixel size, clamped to available

The Styles helper re-exports these as plain constants so you can write width(MATCH_PARENT) after a static import.

Predicates: Size.isWrapContent(int), Size.isMatchParent(int), Size.isFixed(int).

Threading

Operation Thread
State.set(...) Any (auto-hops to render thread)
Listener callbacks Render thread
Event handlers (mouse/key) Render thread
UIContext mutations Render thread
Toast.info / show enqueue Any (auto-hops)

If you're processing a download or running a worker thread, just call state.set(newValue) and let the library bring the update home.

Clone this wiki locally