Skip to content

UI: standardize the UI example's button, checkbox and panel into engine widgets, and add a slider #1722

Open

Description

@obiot

Summary

The UI example (packages/examples/src/examples/ui/ExampleUI.tsx) builds three widgets by hand that every game with a menu ends up rewriting: ButtonUI, CheckBoxUI and UIContainer. ProgressBar went through this already in 20.8: it started inside the loading screen and is now a public, self-drawing widget with typed settings. This issue does the same for the remaining three, using ProgressBar as the template, and adds the one control an options menu still lacks: a slider.

The shared shape:

  • a typed settings object (XxxSettings, exported with the class)
  • draws itself with renderer primitives by default, so it works with no assets on Canvas, WebGL and WebGPU
  • an optional skin for atlas regions / nine-slice art, which is what the example uses today
  • a public value plus onChange, rather than private state

Once they exist, the example uses them and its three local classes are deleted, so the demo looks the same but shows engine API.

Problems shared by the three example classes

  • the label is a sibling the caller must add. btn.label / cb.label are created by the widget but added by the caller (panel.addChild(btn1.label)), positioned by hand, not clickable, and left behind when the widget is removed
  • positional constructors tied to the demo's atlas (new CheckBoxUI(x, y, tex, "green_boxCheckmark", "grey_boxCheckmark", "Music ON", "Music OFF")), so none of them can be reused without the demo's art
  • hover is a hard-coded opacity change, and there is no disabled state
  • state is private (isSelected) with no way for the game to be told it changed

1. Button: ButtonUI → UITextButton gains a skin

The engine already has UITextButton (drawn with a RoundRect). ButtonUI is the same thing drawn from images, with a pressed state. Rather than a second button class:

  • UITextButton accepts skin: { atlas, normal, pressed?, hover?, disabled? }, regions drawn as nine-slice so a button can be any width
  • pressed state for both the drawn and the skinned look (the example shifts down by the height difference of the pressed art; keep that behaviour for skins)
  • onClick callback in settings, so a button doesn't need a subclass for one action
  • enabled flag
  • fix bindKey: UITextButton stores it (uitextbutton.ts:111) and never uses it. Either wire it so the key triggers the click, or remove it

2. Checkbox: CheckBoxUI → UICheckbox

  • new src/renderable/ui/uicheckbox.ts, extending UIBaseElement so the box and label are children
  • settings: checked, label (or labelOn / labelOff, which the example uses), onChange, enabled, size, label text style, boxColor / checkColor, hover colours named as on UITextButton
  • skin: { atlas, on, off } for image checkboxes
  • checked getter/setter and toggle(). Setting checked from code updates the look without calling onChange, so restoring saved settings doesn't trigger game logic; only user input calls it
  • clicking the label toggles it too
  • group: checkboxes sharing a group behave as radio buttons (one checked at a time); members leave the group when deactivated

3. Slider: new UISlider

Not in the example today, but it is the missing piece of an options menu (volume, sensitivity, brightness), and it shares everything above.

  • new src/renderable/ui/uislider.ts, extending UIBaseElement
  • settings: min, max, step, value, orientation: "horizontal" | "vertical", onChange, enabled, optional label and value text (with a format callback, e.g. v => v + "%")
  • drawn by default as a track, a filled part and a thumb; skin: { atlas, track, fill?, thumb } with the track and fill as nine-slice
  • drag the thumb, or click / tap the track to jump there; keeps tracking while dragging even when the pointer leaves the track
  • value getter/setter, snapped to step and clamped to min / max. Setting it from code does not call onChange, same rule as the checkbox
  • optional onChangeEnd, called once on release, for work that should not run on every drag step (saving settings, playing a test sound)
  • the mouse wheel over a hovered slider moves it by one step
  • inside a scrolling UIPanel, dragging the thumb moves the slider, not the panel

4. Panel: UIContainer → UIPanel

UIContainer would read as "the UI version of Container"; the example already names its instance "UIPanel".

  • new src/renderable/ui/uipanel.ts, extending UIBaseElement
  • background: drawn by default, or skin: { atlas, region } as a NineSliceSprite (what the example does through createSpriteFromName(..., true)), resized with the panel
  • optional title
  • optional dragging, keeping what the example does correctly today: moveToTop on grab, and consuming onClick / onOver / pointermove so a covered widget underneath doesn't answer too
  • layout: layout: "none" | "column" | "row" | "grid", columns, padding (inside the nine-slice border, below the title), gap, align: "start" | "center" | "end" | "stretch", autoSize. Runs when children are added, removed or the panel is resized, not every frame, with relayout() for a child that changes size. "none" (the default) keeps manual placement
  • scrolling: scroll: "none" | "vertical" | "horizontal" | "both". The content area clips (Container#clipping), scrolls with the wheel when hovered and with drag plus momentum on touch, shows a scrollbar only when the content does not fit, and offers scrollTo, scrollToChild, scrollX / scrollY and onScroll
    • children scrolled out of view must not receive pointer events
    • a drag that starts on a button scrolls and cancels the press rather than clicking it
    • nested scrolling panels are out of scope for now

Example and docs

  • ExampleUI.tsx uses UITextButton (skinned), UICheckbox and UIPanel; ButtonUI, CheckBoxUI and UIContainer are deleted
  • the options panel gains volume sliders (music, sound FX) wired to audio
  • the demo adds a scrolling panel (e.g. a level list in a grid) to show layout + scroll
  • melonjs-ui-and-text skill: a section per widget, plus symptom-table rows for "onChange fires when loading settings" and "a hidden button in a scrolled panel still gets clicks"
  • CHANGELOG entries under 20.9.0

Tests

  • checkbox: click toggles and calls onChange once; setting checked does not call it; label click toggles; disabled ignores input; a radio group keeps exactly one checked; the skin swaps regions
  • slider: drag and track click set the value; step snapping and clamping; vertical orientation; setting value does not call onChange; onChangeEnd once per drag; wheel steps; disabled ignores input
  • button: skinned pressed state and offset; onClick setting; bindKey (whichever way it is resolved); disabled
  • panel: column, row and grid placement with padding and gap; autoSize grows the nine-slice background; scroll range from content size; wheel and drag scrolling; out-of-view children are not hit; drag cancels a press; a slider drag inside a scrolling panel doesn't scroll it
  • all four draw on Canvas and WebGL

Out of scope

Text input, a resize grip, and nested scrolling panels.

Activity

  1. changed the title [-]UI: standardize the UI example's button, checkbox and panel into engine widgets[/-] [+]UI: standardize the UI example's button, checkbox and panel into engine widgets, and add a slider[/+] on Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions