Reimplement the archived UI widget stack on the new element-tree system

The old widget system (archive/dusk/ui/) depended on infrastructure this
rewrite deleted -- a static X-macro element list, a 9-slice UI_FRAME, and
a global gamepad/keyboard focus stack -- so everything below is rebuilt
on top of the new native uielement_t pool + parent/child tree instead,
gamepad/keyboard-only (no mouse support exists in the input system).

New JS widgets (assets/scripts/), all pure composites of Label/Rectangle
following the Button.js pattern: Checkbox, Tab, Slider, Dropdown. Slider/
Dropdown expose a directionInput(dx, dy) hook so a menu can hand them
LEFT/RIGHT before falling back to cursor movement.

New Menu.js replaces the old global uifocus_t stack: a small push/pop
stack of Menu instances where only the topmost consumes CANCEL/ACCEPT/
direction input each tick (needed for nested modals -- a Settings
sub-page menu plus a "discard changes?" Confirm can be open at once).
Held-direction repeat timing matches the old 0.5s delay / 0.1s repeat.

New overlay composites (added via a new UI.addOverlay()/UI.removeOverlay(),
always drawn/updated after normal roots): FpsCounter, ConsoleOverlay
(one Label per console history line, driven directly rather than via
add() since History (16) exceeds the 8-children-per-element cap), Crop
(letterbox/pillarbox bars), and Fullbox (a reusable tweened full-screen
fade covering both the old fullbox and transition effects -- reuses
shared instances rather than allocate-and-dispose, since disposing an
element from inside its own render() callback risks the same JerryScript
refcount corruption hit earlier in this rewrite).

New Confirm.js (Yes/No dialog) and Settings.js (+ SettingsGeneral/
SettingsInput sub-pages) built on Menu. Display/Audio settings pages are
intentionally not ported -- neither had a real backing engine system
even in the old code. Frame is a flat Rectangle for now, not real 9-slice
(a genuinely bigger native lift, deliberately deferred).

Small native binding additions needed by the above, each with its own
unit test: Time.renderDelta, Console.lineCount/getLine/visible/
consumeDirty, a new Screen module, a new Locale module, Input.deadzone,
a new Save module, and Label.color (was missing entirely). Also fixes
a real gap from the UI.addOverlay() work: uiElementDispose() wasn't
removing disposed elements from the new overlay root array, which would
have left dangling ids behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 09:05:11 -05:00
parent 7ee04c78cd
commit b639bc6c4f
60 changed files with 2369 additions and 104 deletions
+49
View File
@@ -0,0 +1,49 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Gamepad/keyboard-only for now -- no mouse support exists in the input
// system yet. Something else (e.g. a future Menu widget) is responsible
// for moving `highlighted` between multiple buttons; a Button only knows
// how to fire onSelect() when ACCEPT is pressed while it's the highlighted
// one.
class Button extends UIElement {
init() {
this.background = new Rectangle();
this.background.width = 120;
this.background.height = 32;
this.background.color = Button.COLOR_NORMAL;
this.add(this.background);
this.label = new Label();
this.label.x = 8;
this.label.y = 8;
this.add(this.label);
this.highlighted = false;
this.onSelect = null;
}
get text() { return this.label.text; }
set text(value) { this.label.text = value; }
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.background.color = highlighted
? Button.COLOR_HIGHLIGHTED
: Button.COLOR_NORMAL;
}
update() {
this.updateChildren();
if(this.highlighted && Input.pressed(InputBind.ACCEPT) && this.onSelect) {
this.onSelect();
}
}
}
Button.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Button.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
module.exports = Button;
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// A single label showing "Y "/"N " + the caller's text -- no background,
// matching the old widget exactly. Toggling itself (checked) is driven
// externally, typically by a Menu handing it LEFT/RIGHT via
// directionInput() while this checkbox is the highlighted item.
class Checkbox extends UIElement {
init() {
this.label = new Label();
this.add(this.label);
this._text = '';
this._checked = false;
this.highlighted = false;
this._refresh();
}
get text() { return this._text; }
set text(value) {
this._text = value;
this._refresh();
}
get checked() { return this._checked; }
set checked(value) {
this._checked = value;
this._refresh();
}
toggle() {
this.checked = !this.checked;
}
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.label.color = highlighted
? Checkbox.COLOR_HIGHLIGHTED
: Checkbox.COLOR_NORMAL;
}
// Menu direction-input opt-out hook: LEFT/RIGHT toggles and consumes
// the input; UP/DOWN falls through to default cursor movement.
directionInput(dx, dy) {
if(dx === 0) return false;
this.toggle();
return true;
}
_refresh() {
this.label.text = (this._checked ? 'Y ' : 'N ') + this._text;
}
}
Checkbox.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Checkbox.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
module.exports = Checkbox;
+110
View File
@@ -0,0 +1,110 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Menu = require('./Menu.js');
var Button = require('./Button.js');
// A Yes/No dialog: dim full-screen backdrop + a flat-color panel (see
// Fullbox.js/plan notes on why there's no real 9-slice Frame yet) +
// text + a 2-item Menu. Confirm.open(text, callback) lazily creates and
// reuses ONE shared instance rather than allocating fresh every time --
// the Menu stack already guarantees at most one confirm is ever
// meaningfully active, so there's nothing to gain from a fresh instance
// per call, and it sidesteps ever needing to dispose one (see Fullbox.js
// for why disposing from inside your own callback chain is risky).
class Confirm extends UIElement {
init() {
this.backdrop = new Rectangle();
this.backdrop.color = Confirm.COLOR_BACKDROP;
this.add(this.backdrop);
this.panel = new Rectangle();
this.panel.width = Confirm.PANEL_WIDTH;
this.panel.height = Confirm.PANEL_HEIGHT;
this.panel.color = Confirm.COLOR_PANEL;
this.add(this.panel);
this.label = new Label();
this.add(this.label);
this.menu = new Menu();
this.menu.cols = 2;
this.add(this.menu);
this.confirmButton = new Button();
this.confirmButton.text = 'Confirm';
this.menu.addItem(this.confirmButton);
this.cancelButton = new Button();
this.cancelButton.text = 'Cancel';
this.menu.addItem(this.cancelButton);
this.menu.onSelected = (index) => this._finish(index === 0);
// CANCEL (the bind, not the button) also closes the menu without
// firing onSelected -- treat that the same as picking Cancel.
this.menu.onClosed = () => this._finish(false);
this._callback = null;
this._finishing = false;
}
open(text, callback) {
this.label.text = text;
this._callback = callback;
this._finishing = false;
this._layout();
UI.add(this);
this.menu.open();
}
_finish(result) {
// menu.onClosed calling back into _finish (via close() below) would
// otherwise recurse into itself.
if(this._finishing) return;
this._finishing = true;
this.menu.close();
UI.remove(this);
const callback = this._callback;
this._callback = null;
if(callback) callback(result);
}
_layout() {
this.backdrop.width = Screen.width;
this.backdrop.height = Screen.height;
const panelX = (Screen.width - Confirm.PANEL_WIDTH) / 2;
const panelY = (Screen.height - Confirm.PANEL_HEIGHT) / 2;
this.panel.x = panelX;
this.panel.y = panelY;
this.label.x = panelX + Confirm.PADDING;
this.label.y = panelY + Confirm.PADDING;
this.confirmButton.x = panelX + Confirm.PADDING;
this.confirmButton.y = panelY + Confirm.PANEL_HEIGHT - Confirm.PADDING - 32;
this.cancelButton.x = this.confirmButton.x + 130;
this.cancelButton.y = this.confirmButton.y;
}
}
Confirm.PANEL_WIDTH = 300;
Confirm.PANEL_HEIGHT = 120;
Confirm.PADDING = 16;
Confirm.COLOR_BACKDROP = { r: 0, g: 0, b: 0, a: 160 };
Confirm.COLOR_PANEL = { r: 40, g: 40, b: 40, a: 255 };
Confirm.open = function(text, callback) {
if(!Confirm._shared) Confirm._shared = new Confirm();
Confirm._shared.open(text, callback);
return Confirm._shared;
};
module.exports = Confirm;
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// One Label per console history slot, refreshed only when
// Console.consumeDirty() says the history actually changed -- reuses
// the normal Label/spritebatch render path instead of the old
// hand-rolled mesh-cache renderer.
//
// These Labels are NOT add()ed as children -- UIElement.add() caps at 8
// children per element, and CONSOLE_HISTORY_MAX (Console.lineCount) is
// 16, over that cap. Instead they're driven directly: render()/update()
// called explicitly below, and their world position is just their own
// x/y (never having a parent means nothing offsets it).
class ConsoleOverlay extends UIElement {
init() {
this.lines = [];
for(let i = 0; i < Console.lineCount; i++) {
const label = new Label();
label.x = 4;
label.y = 4 + i * ConsoleOverlay.LINE_HEIGHT;
this.lines.push(label);
}
this._refresh();
}
update() {
this.updateChildren();
if(Console.consumeDirty()) this._refresh();
for(let i = 0; i < this.lines.length; i++) this.lines[i].update();
}
// Overridden (rather than left to auto-render) so the console can be
// hidden entirely without disposing/rebuilding its Labels.
render() {
this.renderChildren();
if(!Console.visible) return;
for(let i = 0; i < this.lines.length; i++) this.lines[i].render();
}
// this.lines was never add()ed, so the normal dispose() cascade can't
// reach them -- release them explicitly first, or they'd leak their
// pool slots.
dispose() {
for(let i = 0; i < this.lines.length; i++) this.lines[i].dispose();
super.dispose();
}
_refresh() {
for(let i = 0; i < this.lines.length; i++) {
this.lines[i].text = Console.getLine(i);
}
}
}
ConsoleOverlay.LINE_HEIGHT = 12;
module.exports = ConsoleOverlay;
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Up to 4 black letterbox/pillarbox bars sized from Screen.scanX/Y/
// scanWidth/scanHeight vs Screen.width/height. Recomputed every tick,
// unconditionally -- cheap (four rectangle field writes), and a no-op
// when the scan area already covers the full viewport (Rectangle
// already skips rendering at width/height <= 0).
class Crop extends UIElement {
init() {
this.top = new Rectangle();
this.bottom = new Rectangle();
this.left = new Rectangle();
this.right = new Rectangle();
this.top.color = Crop.COLOR_BARS;
this.bottom.color = Crop.COLOR_BARS;
this.left.color = Crop.COLOR_BARS;
this.right.color = Crop.COLOR_BARS;
this.add(this.top);
this.add(this.bottom);
this.add(this.left);
this.add(this.right);
}
update() {
this.updateChildren();
this._refresh();
}
_refresh() {
const scanX = Screen.scanX;
const scanY = Screen.scanY;
const scanRight = scanX + Screen.scanWidth;
const scanBottom = scanY + Screen.scanHeight;
this.top.x = 0;
this.top.y = 0;
this.top.width = Screen.width;
this.top.height = scanY;
this.bottom.x = 0;
this.bottom.y = scanBottom;
this.bottom.width = Screen.width;
this.bottom.height = Screen.height - scanBottom;
this.left.x = 0;
this.left.y = scanY;
this.left.width = scanX;
this.left.height = Screen.scanHeight;
this.right.x = scanRight;
this.right.y = scanY;
this.right.width = Screen.width - scanRight;
this.right.height = Screen.scanHeight;
}
}
Crop.COLOR_BARS = { r: 0, g: 0, b: 0, a: 255 };
module.exports = Crop;
+68
View File
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Label + a "< option >"-style value label, cycling a caller-provided
// options array. Same directionInput()-consumes-LEFT/RIGHT shape as
// Slider, stepping the selected index (with wraparound) instead of a
// numeric value.
class Dropdown extends UIElement {
init() {
this.label = new Label();
this.add(this.label);
this.valueLabel = new Label();
this.valueLabel.x = 100;
this.add(this.valueLabel);
this.options = [];
this._index = 0;
this.highlighted = false;
this.onChange = null;
this._refresh();
}
get text() { return this.label.text; }
set text(value) { this.label.text = value; }
get selectedIndex() { return this._index; }
set selectedIndex(index) {
if(this.options.length === 0) {
this._index = 0;
this._refresh();
return;
}
const count = this.options.length;
this._index = ((index % count) + count) % count;
this._refresh();
if(this.onChange) this.onChange(this._index, this.options[this._index]);
}
get selected() { return this.options[this._index]; }
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.label.color = highlighted
? Dropdown.COLOR_HIGHLIGHTED
: Dropdown.COLOR_NORMAL;
}
directionInput(dx, dy) {
if(dx === 0) return false;
this.selectedIndex = this._index + dx;
return true;
}
_refresh() {
const value = this.options[this._index];
this.valueLabel.text = value !== undefined ? ('< ' + value + ' >') : '< >';
}
}
Dropdown.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Dropdown.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
module.exports = Dropdown;
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// extends UIElement (not Label) deliberately: only a UIElement/SCRIPTED
// instance's render()/update() override actually gets dispatched by the
// engine (the native Label/Rectangle callback tables never consult a
// subclass's override -- they're not scripted types themselves). So this
// composes a child Label instead of subclassing one, same as Button.
//
// Overrides render() rather than update(): update()/uiUpdate() only run
// on the one fixed-timestep tick per real frame that actually crosses a
// DUSK_TIME_STEP boundary (see Time.renderDelta's doc), but an FPS
// counter needs to sample every real frame -- which is exactly what
// render() does.
class FpsCounter extends UIElement {
init() {
this.label = new Label();
this.add(this.label);
this._average = 0;
}
render() {
const delta = Time.renderDelta;
if(delta > 0) {
const fps = 1 / delta;
this._average = this._average === 0
? fps
: this._average + (fps - this._average) * FpsCounter.SMOOTHING;
}
this.label.text = 'FPS: ' + Math.round(this._average);
this.renderChildren();
}
}
// Exponential-moving-average weight per sample -- lower is smoother/
// slower to react, higher tracks instantaneous frame time more closely.
FpsCounter.SMOOTHING = 0.1;
module.exports = FpsCounter;
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// A tweened full-screen color quad -- covers both the old uifullbox
// (full-screen fade) and uitransition (fade transition with a
// completion callback): same shape, just parameterized, so there's no
// separate transition class/global.
//
// extends UIElement composing a child Rectangle (not extends Rectangle)
// for the same reason as FpsCounter: only a UIElement/SCRIPTED
// instance's render() override is actually dispatched by the engine.
// Overrides render() (not update()) so the tween advances every real
// frame, not just on fixed-timestep ticks.
//
// A finished Fullbox is only removed from the overlay roots, never
// disposed -- disposing an element from inside its own render()
// callback would free its JS instance registration while a native C
// caller further up the same call stack still holds it, risking the
// exact kind of delayed JerryScript refcount corruption this project
// has already hit once. Fullbox.under()/over() lazily create and cache
// one shared, reusable instance each (mirroring the old UNDER/OVER
// singletons), so repeated fades cost zero additional pool slots.
class Fullbox extends UIElement {
init() {
this.rect = new Rectangle();
this.add(this.rect);
this._from = Fullbox.COLOR_TRANSPARENT;
this._to = Fullbox.COLOR_TRANSPARENT;
this._duration = 0;
this._t = 0;
this._onComplete = null;
this._active = false;
}
// Starts (or restarts) this Fullbox tweening rect.color from
// options.fromColor to options.toColor over options.duration seconds,
// calling options.onComplete() once when done. Adds itself to the
// overlay roots if it isn't already there.
play(options) {
this._from = options.fromColor || Fullbox.COLOR_TRANSPARENT;
this._to = options.toColor || Fullbox.COLOR_TRANSPARENT;
this._duration = options.duration || 0;
this._t = 0;
this._onComplete = options.onComplete || null;
this._active = true;
this.rect.width = Screen.width;
this.rect.height = Screen.height;
this.rect.color = this._from;
UI.addOverlay(this);
}
render() {
if(this._active) {
this._t += Time.renderDelta;
const ratio = this._duration > 0
? Math.min(1, this._t / this._duration)
: 1;
this.rect.color = Fullbox._lerpColor(this._from, this._to, ratio);
if(ratio >= 1) {
this._active = false;
UI.removeOverlay(this);
if(this._onComplete) this._onComplete();
}
}
this.renderChildren();
}
}
Fullbox.COLOR_TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
Fullbox._lerpColor = function(from, to, t) {
return {
r: from.r + (to.r - from.r) * t,
g: from.g + (to.g - from.g) * t,
b: from.b + (to.b - from.b) * t,
a: from.a + (to.a - from.a) * t
};
};
Fullbox.under = function() {
if(!Fullbox._under) Fullbox._under = new Fullbox();
return Fullbox._under;
};
Fullbox.over = function() {
if(!Fullbox._over) Fullbox._over = new Fullbox();
return Fullbox._over;
};
module.exports = Fullbox;
+187
View File
@@ -0,0 +1,187 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Gamepad/keyboard focus-navigation, replacing the old global uifocus_t
// stack. A Menu's children still render/update through the normal tree
// (add()), but navigation uses its own `items` list -- addItem() calls
// add() and also tracks the widget as focusable, so decorative elements
// (a title Label, a Rectangle background) just use add() directly and
// never enter navigation.
//
// open()/close() push/pop this Menu on a small global stack -- only the
// topmost entry consumes CANCEL/ACCEPT/direction input each tick, so a
// Menu opened on top of another (e.g. a Settings sub-page menu, or a
// Confirm dialog) doesn't fight it for input; everything underneath
// keeps rendering/updating, just doesn't react to input until it's
// topmost again.
class Menu extends UIElement {
init() {
this.items = [];
this.cols = 1;
this.index = 0;
this.onSelected = null;
this.onChanged = null;
this.onClosed = null;
this._heldDir = null;
this._heldTime = 0;
}
addItem(widget) {
this.add(widget);
this.items.push(widget);
return widget;
}
open() {
if(Menu._stack.indexOf(this) !== -1) return;
Menu._stack.push(this);
const item = this.items[this.index];
if(item && item.setHighlighted) item.setHighlighted(true);
}
// Closes this Menu and anything opened on top of it, matching the old
// uifocus_t's uiFocusPopItem (popping a mid-stack item also pops
// everything above it).
close() {
const idx = Menu._stack.indexOf(this);
if(idx === -1) return;
while(Menu._stack.length > idx) {
Menu._stack.pop()._onClosed();
}
}
// Safety net: dispose() has no override-preserving trampoline the way
// render()/update() do (see UIElement's dispose() docs), so a bare
// this.menu.dispose() would leave a disposed instance stuck at the top
// of Menu._stack forever if the caller forgot to close() first --
// always close() (a no-op if never opened) before the native cascade.
dispose() {
this.close();
super.dispose();
}
_onClosed() {
for(let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if(item.setHighlighted) item.setHighlighted(false);
}
if(this.onClosed) this.onClosed();
}
update() {
this.updateChildren();
if(Menu.top() !== this) return;
if(Input.pressed(InputBind.CANCEL)) {
this.close();
return;
}
if(Input.pressed(InputBind.ACCEPT) && this.onSelected) {
this.onSelected(this.index, this.items[this.index]);
}
this._handleDirection();
}
_handleDirection() {
const dir = this._currentDirection();
if(!dir) {
this._heldDir = null;
this._heldTime = 0;
return;
}
if(dir !== this._heldDir) {
this._heldDir = dir;
this._heldTime = 0;
this._moveDirection(dir.dx, dir.dy);
return;
}
this._heldTime += Time.delta;
if(this._heldTime >= Menu.HOLD_DELAY) {
this._heldTime -= Menu.HOLD_REPEAT;
this._moveDirection(dir.dx, dir.dy);
}
}
// Identity-stable direction markers so _handleDirection's `dir !==
// this._heldDir` check works without allocating a fresh object (and
// therefore never matching) every tick.
_currentDirection() {
if(Input.isDown(InputBind.LEFT)) return Menu.DIR_LEFT;
if(Input.isDown(InputBind.RIGHT)) return Menu.DIR_RIGHT;
if(Input.isDown(InputBind.UP)) return Menu.DIR_UP;
if(Input.isDown(InputBind.DOWN)) return Menu.DIR_DOWN;
return null;
}
// Gives the currently-highlighted item first refusal (a Slider/
// Dropdown/Checkbox consuming LEFT/RIGHT itself), falling back to
// moving the cursor -- mirrors uifocus_t's per-item `direction`
// callback.
_moveDirection(dx, dy) {
if(this.items.length === 0) return;
const item = this.items[this.index];
if(item && typeof item.directionInput === 'function') {
if(item.directionInput(dx, dy)) return;
}
this._moveIndex(dx, dy);
}
_moveIndex(dx, dy) {
const count = this.items.length;
if(count === 0) return;
const cols = Math.max(1, this.cols);
const rows = Math.ceil(count / cols);
const col = ((this.index % cols) + dx + cols) % cols;
const row = ((Math.floor(this.index / cols)) + dy + rows) % rows;
let next = row * cols + col;
if(next >= count) next = count - 1;
this._setIndex(next);
}
_setIndex(next) {
if(next === this.index) return;
const oldItem = this.items[this.index];
if(oldItem && oldItem.setHighlighted) oldItem.setHighlighted(false);
this.index = next;
const newItem = this.items[this.index];
if(newItem && newItem.setHighlighted) newItem.setHighlighted(true);
if(this.onChanged) this.onChanged(this.index, newItem);
}
}
Menu._stack = [];
Menu.top = function() {
return Menu._stack.length > 0 ? Menu._stack[Menu._stack.length - 1] : null;
};
Menu.HOLD_DELAY = 0.5;
Menu.HOLD_REPEAT = 0.1;
Menu.DIR_LEFT = { dx: -1, dy: 0 };
Menu.DIR_RIGHT = { dx: 1, dy: 0 };
Menu.DIR_UP = { dx: 0, dy: -1 };
Menu.DIR_DOWN = { dx: 0, dy: 1 };
module.exports = Menu;
+21 -16
View File
@@ -1,6 +1,9 @@
var Player = require('./Player.js');
var PlayerCamera = require('./PlayerCamera.js');
var TestPlane = require('./TestPlane.js');
var TestUIElement = require('./TestUIElement.js');
var FpsCounter = require('./FpsCounter.js');
var ConsoleOverlay = require('./ConsoleOverlay.js');
class OverworldScene {
constructor() {
@@ -11,15 +14,14 @@ class OverworldScene {
this.plane = new TestPlane();
this.camera = new PlayerCamera(this.player);
this.rect = new Rectangle();
this.rect.color = { r: 255, g: 0, b: 0, a: 255 };
this.rect.width = 100;
this.rect.height = 100;
UI.add(this.rect);
this.label = new Label();
this.label.text = 'Hello World!';
this.rect.add(this.label);
this.testUi = new TestUIElement();
UI.add(this.testUi);
this.fpsCounter = new FpsCounter();
UI.addOverlay(this.fpsCounter);
this.consoleOverlay = new ConsoleOverlay();
UI.addOverlay(this.consoleOverlay);
}
lateUpdate() {
@@ -31,16 +33,19 @@ class OverworldScene {
this.player.dispose();
this.plane.dispose();
// rect.dispose() cascades to label (added as its child) and removes
// itself from UI's render roots -- no separate UI.remove()/dispose()
// needed for either.
this.rect.dispose();
// testUi.dispose() cascades to its background/label children and
// removes itself from UI's render roots -- no separate UI.remove()
// needed.
this.testUi.dispose();
this.fpsCounter.dispose();
this.consoleOverlay.dispose();
this.camera = null;
this.camera = null;
this.player = null;
this.plane = null;
this.label = null;
this.rect = null;
this.testUi = null;
this.fpsCounter = null;
this.consoleOverlay = null;
}
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Menu = require('./Menu.js');
var Tab = require('./Tab.js');
var Confirm = require('./Confirm.js');
var SettingsGeneral = require('./SettingsGeneral.js');
var SettingsInput = require('./SettingsInput.js');
// Display/Audio pages are deliberately not here -- neither has any real
// engine system behind it, even in the old code (no resolution/
// fullscreen system, no audio subsystem at all).
var TABS = [
{ name: 'General', ctor: SettingsGeneral },
{ name: 'Input', ctor: SettingsInput }
];
// Owns a tabs Menu (General/Input) and swaps in the active tab's page
// widgets into a second, page-scoped Menu -- both Menus are open on the
// navigation stack simultaneously (tabsMenu below, pageMenu on top),
// matching the old settings screen's nested-menu behavior. Switching
// away from a dirty page opens a Confirm first.
class Settings extends UIElement {
init() {
this.tabsMenu = new Menu();
this.tabsMenu.cols = TABS.length;
this.add(this.tabsMenu);
this.tabs = TABS.map((def, index) => {
const tab = new Tab();
tab.text = def.name;
tab.x = index * 90;
this.tabsMenu.addItem(tab);
return tab;
});
this.tabsMenu.onSelected = (index) => this._activateTab(index);
this.page = null;
this.pageMenu = null;
this._activeTabIndex = -1;
}
open() {
UI.add(this);
this.tabsMenu.open();
this._activateTab(0);
}
close() {
this.tabsMenu.close();
if(this.pageMenu) this.pageMenu.close();
UI.remove(this);
}
_activateTab(index) {
if(index === this._activeTabIndex) return;
if(this.page && this.page.hasChanges && this.page.hasChanges()) {
Confirm.open('Discard changes?', (confirmed) => {
if(confirmed) this._swapTab(index);
});
return;
}
this._swapTab(index);
}
_swapTab(index) {
// pageMenu.dispose() cascades to every widget the outgoing page
// handed it via menuItems -- the page itself is a plain object (see
// SettingsGeneral.js), nothing else to release.
if(this.pageMenu) this.pageMenu.dispose();
for(let i = 0; i < this.tabs.length; i++) {
this.tabs[i].setHighlighted(i === index);
}
const def = TABS[index];
this.page = new def.ctor();
if(this.page.load) this.page.load();
this.pageMenu = new Menu();
this.pageMenu.y = 40;
const items = this.page.menuItems || [];
for(let i = 0; i < items.length; i++) this.pageMenu.addItem(items[i]);
this.add(this.pageMenu);
this.pageMenu.open();
this._activeTabIndex = index;
}
}
module.exports = Settings;
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Dropdown = require('./Dropdown.js');
var Button = require('./Button.js');
// A plain class, not a UIElement -- it owns no visual tree of its own.
// Its widgets are handed to Settings.js's pageMenu (via menuItems),
// which owns their parenting/rendering/disposal; this only holds
// load()/apply()/hasChanges() logic, mirroring the old settings system's
// per-tab def struct.
class SettingsGeneral {
constructor() {
this.dropdown = new Dropdown();
this.dropdown.text = 'Language';
this.dropdown.options = ['en-US', 'ja-JP', 'es-MX'];
this.applyButton = new Button();
this.applyButton.text = 'Apply';
this.applyButton.y = 40;
this.applyButton.onSelect = () => this.apply();
this._loadedIndex = 0;
}
get menuItems() {
return [this.dropdown, this.applyButton];
}
load() {
const index = this.dropdown.options.indexOf(Locale.current);
this.dropdown.selectedIndex = index >= 0 ? index : 0;
this._loadedIndex = this.dropdown.selectedIndex;
}
apply() {
Locale.set(this.dropdown.options[this.dropdown.selectedIndex]);
this._loadedIndex = this.dropdown.selectedIndex;
}
hasChanges() {
return this.dropdown.selectedIndex !== this._loadedIndex;
}
}
module.exports = SettingsGeneral;
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Slider = require('./Slider.js');
var Button = require('./Button.js');
// A plain class, not a UIElement -- see SettingsGeneral.js for why.
class SettingsInput {
constructor() {
this.deadzoneSlider = new Slider();
this.deadzoneSlider.text = 'Deadzone';
this.deadzoneSlider.min = 0;
this.deadzoneSlider.max = 1;
this.deadzoneSlider.step = 0.05;
this.applyButton = new Button();
this.applyButton.text = 'Apply';
this.applyButton.y = 40;
this.applyButton.onSelect = () => this.apply();
this._loadedValue = 0;
}
get menuItems() {
return [this.deadzoneSlider, this.applyButton];
}
load() {
this.deadzoneSlider.value = Input.deadzone;
this._loadedValue = this.deadzoneSlider.value;
}
apply() {
Input.deadzone = this.deadzoneSlider.value;
Save.write();
this._loadedValue = this.deadzoneSlider.value;
}
hasChanges() {
return this.deadzoneSlider.value !== this._loadedValue;
}
}
module.exports = SettingsInput;
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Name label + value label + a track/fill Rectangle pair. Unlike Button,
// a Slider doesn't fire on ACCEPT -- it consumes LEFT/RIGHT itself via
// directionInput(), stepping value with wraparound at min/max, which is
// how a Menu gives the currently-highlighted item first refusal on
// direction input before falling back to moving the cursor.
class Slider extends UIElement {
init() {
this.nameLabel = new Label();
this.add(this.nameLabel);
this.valueLabel = new Label();
this.valueLabel.x = 100;
this.add(this.valueLabel);
this.track = new Rectangle();
this.track.y = 16;
this.track.width = 100;
this.track.height = 4;
this.track.color = Slider.COLOR_TRACK;
this.add(this.track);
this.fill = new Rectangle();
this.fill.y = 16;
this.fill.height = 4;
this.fill.color = Slider.COLOR_FILL;
this.add(this.fill);
this._min = 0;
this._max = 1;
this.step = 0.1;
this._value = 0;
this.highlighted = false;
this.onChange = null;
this._refresh();
}
get text() { return this.nameLabel.text; }
set text(value) { this.nameLabel.text = value; }
get min() { return this._min; }
set min(value) { this._min = value; this._refresh(); }
get max() { return this._max; }
set max(value) { this._max = value; this._refresh(); }
get value() { return this._value; }
set value(v) {
this._value = Math.min(this._max, Math.max(this._min, v));
this._refresh();
if(this.onChange) this.onChange(this._value);
}
setHighlighted(highlighted) {
this.highlighted = highlighted;
this.nameLabel.color = highlighted
? Slider.COLOR_HIGHLIGHTED
: Slider.COLOR_NORMAL;
}
directionInput(dx, dy) {
if(dx === 0) return false;
let next = this._value + dx * this.step;
const range = this._max - this._min;
if(range > 0) {
// Wrap within [min, max) rather than clamp, matching the old
// slider's step-with-wraparound behavior.
next = this._min + (((next - this._min) % range) + range) % range;
}
this.value = next;
return true;
}
_refresh() {
this.valueLabel.text = String(Math.round(this._value * 100) / 100);
const ratio = this._max > this._min
? (this._value - this._min) / (this._max - this._min)
: 0;
this.fill.width = this.track.width * ratio;
}
}
Slider.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
Slider.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
Slider.COLOR_TRACK = { r: 80, g: 80, b: 80, a: 255 };
Slider.COLOR_FILL = { r: 0, g: 160, b: 220, a: 255 };
module.exports = Slider;
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// A background Rectangle + Label, structurally a re-skinned Button --
// "active" (which content tab is currently shown) plays the same role
// Button's "highlighted" plays, so setHighlighted() is how a Menu drives
// it: a tab strip's Menu treats "cursor is here" and "this tab's content
// is showing" as the same thing, matching the old widget's behavior.
class Tab extends UIElement {
init() {
this.background = new Rectangle();
this.background.width = 80;
this.background.height = 24;
this.add(this.background);
this.label = new Label();
this.label.x = 4;
this.label.y = 4;
this.add(this.label);
this.active = false;
this._refresh();
}
get text() { return this.label.text; }
set text(value) { this.label.text = value; }
setHighlighted(active) {
this.active = active;
this._refresh();
}
_refresh() {
this.background.color = this.active
? Tab.COLOR_ACTIVE
: Tab.COLOR_INACTIVE;
}
}
Tab.COLOR_ACTIVE = { r: 0, g: 160, b: 0, a: 255 };
Tab.COLOR_INACTIVE = { r: 160, g: 0, b: 0, a: 255 };
module.exports = Tab;
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
var Button = require('./Button.js');
var Menu = require('./Menu.js');
var Settings = require('./Settings.js');
// No render() override -- UIElement's base render() auto-renders whatever
// was added() here, in add-order: background first, labels, then the
// menu (and its two buttons) on top of it.
class TestUIElement extends UIElement {
init() {
this.background = new Rectangle();
this.background.width = 220;
this.background.height = 128;
this.background.color = { r: 255, g: 0, b: 0, a: 255 };
this.add(this.background);
this.label = new Label();
this.label.text = 'Hello World!';
this.label.x = 8;
this.label.y = 8;
this.add(this.label);
this.label2 = new Label();
this.label2.text = 'Second label!';
this.label2.x = 8;
this.label2.y = 24;
this.add(this.label2);
// A navigable 2-item Menu -- UP/DOWN moves the highlighted cursor
// between the buttons, ACCEPT fires whichever one is highlighted.
this.menu = new Menu();
this.add(this.menu);
this.helloButton = new Button();
this.helloButton.text = 'Say hello';
this.helloButton.x = 8;
this.helloButton.y = 48;
this.helloButton.onSelect = () => Console.print('Hello!');
this.menu.addItem(this.helloButton);
this.settingsButton = new Button();
this.settingsButton.text = 'Open settings';
this.settingsButton.x = 8;
this.settingsButton.y = 84;
this.settingsButton.onSelect = () => new Settings().open();
this.menu.addItem(this.settingsButton);
this.menu.open();
}
}
module.exports = TestUIElement;
+3
View File
@@ -15,4 +15,7 @@ add_subdirectory(display)
add_subdirectory(time)
add_subdirectory(console)
add_subdirectory(input)
add_subdirectory(screen)
add_subdirectory(locale)
add_subdirectory(save)
add_subdirectory(ui)
@@ -35,9 +35,46 @@ moduleBaseFunction(moduleConsolePrint) {
return jerry_undefined();
}
moduleBaseFunction(moduleConsoleGetLineCount) {
return jerry_number(CONSOLE_HISTORY_MAX);
}
moduleBaseFunction(moduleConsoleGetLine) {
moduleBaseRequireArgs(1); moduleBaseRequireNumber(0);
int32_t index = moduleBaseArgInt(0);
if(index < 0 || index >= CONSOLE_HISTORY_MAX) {
return moduleBaseThrow("Console.getLine: index out of range");
}
return jerry_string_sz(CONSOLE.line[index]);
}
moduleBaseFunction(moduleConsoleGetVisible) {
return jerry_boolean(CONSOLE.visible);
}
// Returns the current dirty flag and clears it -- mirrors the
// check-then-clear contract CONSOLE.dirty already documents for native
// consumers, so a script-side console renderer only rebuilds when
// something actually changed since the last time it asked.
moduleBaseFunction(moduleConsoleConsumeDirty) {
bool_t dirty = CONSOLE.dirty;
CONSOLE.dirty = false;
return jerry_boolean(dirty);
}
void moduleConsoleInit(void) {
jerry_value_t consoleObj = jerry_object();
moduleBaseDefineMethod(consoleObj, "print", moduleConsolePrint);
moduleBaseDefineProperty(
consoleObj, "lineCount", moduleConsoleGetLineCount, NULL
);
moduleBaseDefineMethod(consoleObj, "getLine", moduleConsoleGetLine);
moduleBaseDefineProperty(
consoleObj, "visible", moduleConsoleGetVisible, NULL
);
moduleBaseDefineMethod(consoleObj, "consumeDirty", moduleConsoleConsumeDirty);
moduleBaseSetValue("Console", consoleObj);
jerry_value_free(consoleObj);
}
@@ -11,7 +11,11 @@
* Registers the global `Console` object, exposing `Console.print(...)`,
* which stringifies and space-joins its arguments (like console.log) and
* forwards the result to the engine's consolePrint() (see
* console/console.h).
* console/console.h). Also exposes read access to the console's fixed
* CONSOLE_HISTORY_MAX-line ring buffer for a script-side renderer:
* `Console.lineCount` (always CONSOLE_HISTORY_MAX), `Console.getLine(i)`,
* `Console.visible`, and `Console.consumeDirty()` (returns whether the
* history changed since the last call, clearing the flag).
*/
void moduleConsoleInit(void);
@@ -54,6 +54,16 @@ moduleBaseFunction(moduleInputReleasedFn) {
return jerry_boolean(inputReleased((inputbind_t)moduleBaseArgInt(0)));
}
moduleBaseFunction(moduleInputGetDeadzone) {
return jerry_number(INPUT.deadzone);
}
moduleBaseFunction(moduleInputSetDeadzone) {
moduleBaseRequireArgs(1); moduleBaseRequireNumber(0);
INPUT.deadzone = moduleBaseArgFloat(0);
return jerry_undefined();
}
void moduleInputInit(void) {
jerry_value_t inputObj = jerry_object();
moduleBaseDefineMethod(inputObj, "bind", moduleInputBindFn);
@@ -63,6 +73,9 @@ void moduleInputInit(void) {
moduleBaseDefineMethod(inputObj, "wasDown", moduleInputWasDownFn);
moduleBaseDefineMethod(inputObj, "pressed", moduleInputPressedFn);
moduleBaseDefineMethod(inputObj, "released", moduleInputReleasedFn);
moduleBaseDefineProperty(
inputObj, "deadzone", moduleInputGetDeadzone, moduleInputSetDeadzone
);
moduleBaseSetValue("Input", inputObj);
jerry_value_free(inputObj);
+2 -1
View File
@@ -9,7 +9,8 @@
/**
* Registers the global `Input` object (bind/value/lastValue/isDown/wasDown/
* pressed/released), plus two constant lookup objects: `InputBind` (the
* pressed/released, plus a read/write `deadzone` property over
* INPUT.deadzone), plus two constant lookup objects: `InputBind` (the
* inputbind_t action enum, e.g. InputBind.UP) and `InputButton` (every
* named physical button in the compiled platform's INPUT_BUTTON_DATA
* table, e.g. InputButton.SELECT -- the exact set varies per platform).
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
modulelocale.c
)
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "modulelocale.h"
#include "script/module/modulebase.h"
#include "locale/localemanager.h"
#include "locale/localeinfo.h"
#include "util/string.h"
#define MODULE_LOCALE_NAME_MAX 32
static const localeinfo_t *MODULE_LOCALE_KNOWN[] = {
&LOCALE_EN_US,
&LOCALE_JP_JP,
&LOCALE_ES_MX,
NULL
};
moduleBaseFunction(moduleLocaleGetCurrent) {
if(!LOCALE.locale) return jerry_string_sz("");
return jerry_string_sz(LOCALE.locale->name);
}
moduleBaseFunction(moduleLocaleSet) {
moduleBaseRequireArgs(1); moduleBaseRequireString(0);
char_t name[MODULE_LOCALE_NAME_MAX];
moduleBaseToString(args[0], name, sizeof(name));
for(const localeinfo_t **info = MODULE_LOCALE_KNOWN; *info; info++) {
if(stringCompare((*info)->name, name) != 0) continue;
errorret_t ret = localeManagerSetLocale(*info);
if(errorIsNotOk(ret)) return moduleBaseThrowError(ret);
return jerry_undefined();
}
return moduleBaseThrow("Locale.set: unknown locale name");
}
void moduleLocaleInit(void) {
jerry_value_t localeObj = jerry_object();
moduleBaseDefineProperty(
localeObj, "current", moduleLocaleGetCurrent, NULL
);
moduleBaseDefineMethod(localeObj, "set", moduleLocaleSet);
moduleBaseSetValue("Locale", localeObj);
jerry_value_free(localeObj);
}
void moduleLocaleDispose(void) {
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
/**
* Registers the global `Locale` object, exposing read-only `current`
* (the active locale's name, e.g. "en-US") and `set(name)` (switches the
* active locale by name -- throws if name doesn't match a known
* localeinfo_t) over the engine's locale system (see
* locale/localemanager.h).
*/
void moduleLocaleInit(void);
/**
* Disposes the locale module's script resources.
*/
void moduleLocaleDispose(void);
+37
View File
@@ -10,6 +10,7 @@
#include "assert/assert.h"
#include "util/string.h"
#include "util/memory.h"
#include "display/color.h"
#include <stdlib.h>
#include <string.h>
@@ -460,3 +461,39 @@ static inline jerry_value_t moduleBaseVec3ToObject(const vec3 v) {
return obj;
}
/**
* Wraps a color_t as a plain JS {r, g, b, a} object.
*/
static inline jerry_value_t moduleBaseColorToObject(const color_t c) {
jerry_value_t obj = jerry_object();
moduleBaseObjectSetNumber(obj, "r", c.r);
moduleBaseObjectSetNumber(obj, "g", c.g);
moduleBaseObjectSetNumber(obj, "b", c.b);
moduleBaseObjectSetNumber(obj, "a", c.a);
return obj;
}
/**
* Reads a plain JS {r, g, b, a} object into a color_t. Any missing
* channel keeps its COLOR_WHITE default.
*/
static inline color_t moduleBaseColorFromObject(const jerry_value_t obj) {
color_t c = COLOR_WHITE;
jerry_value_t rVal = moduleBaseGetProp(obj, "r");
jerry_value_t gVal = moduleBaseGetProp(obj, "g");
jerry_value_t bVal = moduleBaseGetProp(obj, "b");
jerry_value_t aVal = moduleBaseGetProp(obj, "a");
if(jerry_value_is_number(rVal)) c.r = (colorchannel8_t)moduleBaseValueInt(rVal);
if(jerry_value_is_number(gVal)) c.g = (colorchannel8_t)moduleBaseValueInt(gVal);
if(jerry_value_is_number(bVal)) c.b = (colorchannel8_t)moduleBaseValueInt(bVal);
if(jerry_value_is_number(aVal)) c.a = (colorchannel8_t)moduleBaseValueInt(aVal);
jerry_value_free(rVal);
jerry_value_free(gVal);
jerry_value_free(bVal);
jerry_value_free(aVal);
return c;
}
+9
View File
@@ -16,6 +16,9 @@
#include "script/module/scene/modulescene.h"
#include "script/module/console/moduleconsole.h"
#include "script/module/input/moduleinput.h"
#include "script/module/screen/modulescreen.h"
#include "script/module/locale/modulelocale.h"
#include "script/module/save/modulesave.h"
#include "script/module/ui/moduleuielement.h"
#include "script/module/ui/modulelabel.h"
#include "script/module/ui/modulerectangle.h"
@@ -28,6 +31,9 @@ void moduleListInit(void) {
moduleTimeInit();
moduleConsoleInit();
moduleInputInit();
moduleScreenInit();
moduleLocaleInit();
moduleSaveInit();
moduleUiElementInit();
moduleLabelInit();
moduleRectangleInit();
@@ -47,6 +53,9 @@ void moduleListDispose(void) {
moduleRectangleDispose();
moduleLabelDispose();
moduleUiElementDispose();
moduleSaveDispose();
moduleLocaleDispose();
moduleScreenDispose();
moduleInputDispose();
moduleConsoleDispose();
moduleTimeDispose();
+3 -3
View File
@@ -9,9 +9,9 @@
/**
* Registers every script module (Component + its typed per-type
* wrappers, Entity, Scene, require(), Mesh, Time, Console, Input,
* UIElement + Label/Rectangle + UI, the platform globals). Called once
* by scriptManagerInit().
* wrappers, Entity, Scene, require(), Mesh, Time, Console, Input, Screen,
* Locale, Save, UIElement + Label/Rectangle + UI, the platform globals).
* Called once by scriptManagerInit().
*/
void moduleListInit(void);
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
modulesave.c
)
+26
View File
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "modulesave.h"
#include "script/module/modulebase.h"
#include "save/savesettings.h"
moduleBaseFunction(moduleSaveWrite) {
errorret_t ret = saveSettingsWrite();
if(errorIsNotOk(ret)) return moduleBaseThrowError(ret);
return jerry_undefined();
}
void moduleSaveInit(void) {
jerry_value_t saveObj = jerry_object();
moduleBaseDefineMethod(saveObj, "write", moduleSaveWrite);
moduleBaseSetValue("Save", saveObj);
jerry_value_free(saveObj);
}
void moduleSaveDispose(void) {
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
/**
* Registers the global `Save` object, exposing `write()`, which gathers
* all game settings from their owning runtime state and persists them
* (see save/savesettings.h's saveSettingsWrite()).
*/
void moduleSaveInit(void);
/**
* Disposes the save module's script resources.
*/
void moduleSaveDispose(void);
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
modulescreen.c
)
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "modulescreen.h"
#include "script/module/modulebase.h"
#include "display/screen/screen.h"
moduleBaseFunction(moduleScreenGetWidth) {
return jerry_number(SCREEN.width);
}
moduleBaseFunction(moduleScreenGetHeight) {
return jerry_number(SCREEN.height);
}
moduleBaseFunction(moduleScreenGetScanX) {
return jerry_number(SCREEN.scanX);
}
moduleBaseFunction(moduleScreenGetScanY) {
return jerry_number(SCREEN.scanY);
}
moduleBaseFunction(moduleScreenGetScanWidth) {
return jerry_number(SCREEN.scanWidth);
}
moduleBaseFunction(moduleScreenGetScanHeight) {
return jerry_number(SCREEN.scanHeight);
}
void moduleScreenInit(void) {
jerry_value_t screenObj = jerry_object();
moduleBaseDefineProperty(screenObj, "width", moduleScreenGetWidth, NULL);
moduleBaseDefineProperty(screenObj, "height", moduleScreenGetHeight, NULL);
moduleBaseDefineProperty(screenObj, "scanX", moduleScreenGetScanX, NULL);
moduleBaseDefineProperty(screenObj, "scanY", moduleScreenGetScanY, NULL);
moduleBaseDefineProperty(
screenObj, "scanWidth", moduleScreenGetScanWidth, NULL
);
moduleBaseDefineProperty(
screenObj, "scanHeight", moduleScreenGetScanHeight, NULL
);
moduleBaseSetValue("Screen", screenObj);
jerry_value_free(screenObj);
}
void moduleScreenDispose(void) {
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
/**
* Registers the global `Screen` object, exposing read-only `width`/
* `height` (calculated viewport dimensions) and `scanX`/`scanY`/
* `scanWidth`/`scanHeight` (the overscan-safe area within the viewport --
* defaults to the full viewport, letterboxed/pillarboxed on platforms
* that need it) as live getters over the engine's SCREEN state (see
* display/screen/screen.h).
*/
void moduleScreenInit(void);
/**
* Disposes the screen module's script resources.
*/
void moduleScreenDispose(void);
+11
View File
@@ -17,10 +17,21 @@ moduleBaseFunction(moduleTimeGetTime) {
return jerry_number(TIME.time);
}
moduleBaseFunction(moduleTimeGetRenderDelta) {
#ifdef DUSK_TIME_DYNAMIC
return jerry_number(TIME.dynamicDelta);
#else
return jerry_number(TIME.delta);
#endif
}
void moduleTimeInit(void) {
jerry_value_t timeObj = jerry_object();
moduleBaseDefineProperty(timeObj, "delta", moduleTimeGetDelta, NULL);
moduleBaseDefineProperty(timeObj, "time", moduleTimeGetTime, NULL);
moduleBaseDefineProperty(
timeObj, "renderDelta", moduleTimeGetRenderDelta, NULL
);
moduleBaseSetValue("Time", timeObj);
jerry_value_free(timeObj);
}
+7 -2
View File
@@ -9,8 +9,13 @@
/**
* Registers the global `Time` object, exposing `Time.delta` and
* `Time.time` as live getters over the engine's TIME state (see
* time/time.h). Values always reflect the most recent timeUpdate().
* `Time.time` (fixed-timestep, only advance on the one timeUpdate() call
* per tick that crosses a DUSK_TIME_STEP boundary) and `Time.renderDelta`
* (real elapsed time since the previous timeUpdate() call, i.e.
* TIME.dynamicDelta under DUSK_TIME_DYNAMIC or plain TIME.delta
* otherwise -- for per-real-frame effects like FPS counters or tweens
* that shouldn't be gated to the fixed tick) as live getters over the
* engine's TIME state (see time/time.h).
*/
void moduleTimeInit(void);
+16
View File
@@ -35,6 +35,19 @@ moduleBaseFunction(moduleLabelSetText) {
return jerry_undefined();
}
moduleBaseFunction(moduleLabelGetColor) {
moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet);
return moduleBaseColorToObject(UI_ELEMENTS[h->id].label.color);
}
moduleBaseFunction(moduleLabelSetColor) {
moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet);
moduleBaseRequireArgs(1); moduleBaseRequireObject(0);
uiLabelSetColor(h->id, moduleBaseColorFromObject(args[0]));
return jerry_undefined();
}
void moduleLabelInit(void) {
scriptProtoInit(
&MODULE_LABEL_PROTO,
@@ -49,6 +62,9 @@ void moduleLabelInit(void) {
scriptProtoDefineProp(
&MODULE_LABEL_PROTO, "text", moduleLabelGetText, moduleLabelSetText
);
scriptProtoDefineProp(
&MODULE_LABEL_PROTO, "color", moduleLabelGetColor, moduleLabelSetColor
);
}
void moduleLabelDispose(void) {
+1 -1
View File
@@ -12,7 +12,7 @@
* UI_ELEMENT_TYPE_LABEL pool element. Its prototype chains to
* UIElement.prototype (see moduleuielement.h), so x/y/worldX/worldY/
* parent/render/dispose all work on a Label unmodified; Label itself
* only adds a `text` property (read/write).
* adds `text` and `color` properties (both read/write).
*/
void moduleLabelInit(void);
+2 -31
View File
@@ -13,35 +13,6 @@
static scriptproto_t MODULE_RECTANGLE_PROTO;
static jerry_value_t moduleRectangleColorToObject(const color_t c) {
jerry_value_t obj = jerry_object();
moduleBaseObjectSetNumber(obj, "r", c.r);
moduleBaseObjectSetNumber(obj, "g", c.g);
moduleBaseObjectSetNumber(obj, "b", c.b);
moduleBaseObjectSetNumber(obj, "a", c.a);
return obj;
}
static color_t moduleRectangleColorFromObject(const jerry_value_t obj) {
color_t c = COLOR_WHITE;
jerry_value_t rVal = moduleBaseGetProp(obj, "r");
jerry_value_t gVal = moduleBaseGetProp(obj, "g");
jerry_value_t bVal = moduleBaseGetProp(obj, "b");
jerry_value_t aVal = moduleBaseGetProp(obj, "a");
if(jerry_value_is_number(rVal)) c.r = (colorchannel8_t)moduleBaseValueInt(rVal);
if(jerry_value_is_number(gVal)) c.g = (colorchannel8_t)moduleBaseValueInt(gVal);
if(jerry_value_is_number(bVal)) c.b = (colorchannel8_t)moduleBaseValueInt(bVal);
if(jerry_value_is_number(aVal)) c.a = (colorchannel8_t)moduleBaseValueInt(aVal);
jerry_value_free(rVal);
jerry_value_free(gVal);
jerry_value_free(bVal);
jerry_value_free(aVal);
return c;
}
moduleBaseFunction(moduleRectangleConstructor) {
errorret_t ret = moduleUiElementConstructShared(
callInfo->this_value, UI_ELEMENT_TYPE_RECTANGLE
@@ -80,14 +51,14 @@ moduleBaseFunction(moduleRectangleSetHeight) {
moduleBaseFunction(moduleRectangleGetColor) {
moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet);
return moduleRectangleColorToObject(UI_ELEMENTS[h->id].rectangle.color);
return moduleBaseColorToObject(UI_ELEMENTS[h->id].rectangle.color);
}
moduleBaseFunction(moduleRectangleSetColor) {
moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet);
moduleBaseRequireArgs(1); moduleBaseRequireObject(0);
uiRectangleSetColor(h->id, moduleRectangleColorFromObject(args[0]));
uiRectangleSetColor(h->id, moduleBaseColorFromObject(args[0]));
return jerry_undefined();
}
+29
View File
@@ -34,10 +34,39 @@ moduleBaseFunction(moduleUiRemoveFn) {
return jerry_undefined();
}
moduleBaseFunction(moduleUiAddOverlayFn) {
moduleBaseRequireArgs(1); moduleBaseRequireObject(0);
moduleuielementhandle_t *h = moduleUiElementGetFromValue(args[0]);
if(!h) return moduleBaseThrow("Expected a UIElement");
// A root/overlay root and a child are mutually exclusive, same as
// UI.add() -- promoting an existing child detaches it from its parent
// first.
uiElementSetParent(h->id, UI_ELEMENT_ID_INVALID);
if(!uiOverlayRootAdd(h->id)) {
return moduleBaseThrow("UI overlay root capacity exceeded");
}
return jerry_undefined();
}
moduleBaseFunction(moduleUiRemoveOverlayFn) {
moduleBaseRequireArgs(1); moduleBaseRequireObject(0);
moduleuielementhandle_t *h = moduleUiElementGetFromValue(args[0]);
if(!h) return moduleBaseThrow("Expected a UIElement");
uiOverlayRootRemove(h->id);
return jerry_undefined();
}
void moduleUiInit(void) {
jerry_value_t uiObj = jerry_object();
moduleBaseDefineMethod(uiObj, "add", moduleUiAddFn);
moduleBaseDefineMethod(uiObj, "remove", moduleUiRemoveFn);
moduleBaseDefineMethod(uiObj, "addOverlay", moduleUiAddOverlayFn);
moduleBaseDefineMethod(uiObj, "removeOverlay", moduleUiRemoveOverlayFn);
moduleBaseSetValue("UI", uiObj);
jerry_value_free(uiObj);
}
+13 -7
View File
@@ -8,13 +8,19 @@
#pragma once
/**
* Registers the global `UI` object, exposing `UI.add(element)` and
* `UI.remove(element)`. Both take a UIElement (or Label/Rectangle/etc.)
* instance and add/remove it from the engine's render roots (UI.root[]
* in ui/ui.h) -- root elements get their render() called once per frame
* by uiRender(); everything else only renders as part of a root's
* children (see UIElement.add()). A root and a child are mutually
* exclusive: UI.add() detaches the element from any parent first.
* Registers the global `UI` object, exposing `UI.add(element)`/
* `UI.remove(element)` and `UI.addOverlay(element)`/
* `UI.removeOverlay(element)`. All four take a UIElement (or Label/
* Rectangle/etc.) instance; add/remove manage the engine's normal render
* roots (UI.root[] in ui/ui.h), addOverlay/removeOverlay manage a
* second root array (UI.overlayRoot[]) that's always drawn/updated
* strictly after every normal root, for things that must always sit on
* top (debug overlays, screen fades/transitions). Root/overlay-root
* elements get render()/update() called once per frame/tick by
* uiRender()/uiUpdate(); everything else only renders as part of a
* root's children (see UIElement.add()). A root (of either kind) and a
* child are mutually exclusive: UI.add()/UI.addOverlay() detach the
* element from any parent first.
*/
void moduleUiInit(void);
+91 -35
View File
@@ -17,10 +17,12 @@ scriptproto_t MODULE_UI_ELEMENT_PROTO;
// given element id. Indices with no registered instance hold undefined.
static jerry_value_t UI_ELEMENT_JS_INSTANCES[UI_ELEMENT_COUNT_MAX];
// Own (instance, not prototype) property that a construction-time
// override wrap stashes the (sub)class's real render() under -- see
// moduleUiElementConstructShared and moduleUiElementRenderScripted.
// Own (instance, not prototype) properties that a construction-time
// override wrap stashes the (sub)class's real render()/update() under --
// see moduleUiElementConstructShared and moduleUiElementRenderScripted/
// moduleUiElementUpdateScripted.
#define MODULE_UI_ELEMENT_USER_RENDER_PROP "__uiElementUserRender"
#define MODULE_UI_ELEMENT_USER_UPDATE_PROP "__uiElementUserUpdate"
static bool_t moduleUiElementStrictEqual(
const jerry_value_t a,
@@ -38,6 +40,12 @@ static jerry_value_t moduleUiElementRenderMethod(
const jerry_length_t argc
);
static jerry_value_t moduleUiElementUpdateMethod(
const jerry_call_info_t *callInfo,
const jerry_value_t args[],
const jerry_length_t argc
);
// UI_ELEMENT_TYPE_SCRIPTED's render callback: calls the (sub)class's own
// render(), stashed at construction time under
// MODULE_UI_ELEMENT_USER_RENDER_PROP -- see moduleUiElementConstructShared.
@@ -64,45 +72,67 @@ static void moduleUiElementRenderScripted(uielement_t *element) {
jerry_value_free(renderFn);
}
// Calling instance.render() (JS method-call syntax) only reaches native
// code if the property lookup actually resolves to
// UIElement.prototype.render -- if a (sub)class overrides render(), that
// override shadows it and callers bypass uiElementRender() entirely,
// which is what actually tracks parent/worldX/worldY. So if this
// instance's resolved render is anything other than the base one, stash
// the override under MODULE_UI_ELEMENT_USER_RENDER_PROP and shadow
// render itself (as an own property, just on this instance) with the
// native trampoline -- moduleUiElementRenderScripted then calls the
// stashed override from inside uiElementRender(), fully wired up.
static void moduleUiElementWrapUserRenderIfOverridden(
const jerry_value_t thisValue
// UI_ELEMENT_TYPE_SCRIPTED's update callback -- exact update-side twin of
// moduleUiElementRenderScripted, see there for the reasoning.
static void moduleUiElementUpdateScripted(uielement_t *element) {
jerry_value_t instance = UI_ELEMENT_JS_INSTANCES[element->id];
jerry_value_t updateFn = jerry_value_is_object(instance)
? moduleBaseGetProp(instance, MODULE_UI_ELEMENT_USER_UPDATE_PROP)
: jerry_undefined();
if(jerry_value_is_function(updateFn)) {
errorret_t ret = scriptManagerCallValue(
instance, updateFn, "UIElement", "update"
);
errorCatch(errorPrint(ret));
} else {
uiElementUpdateChildren(element);
}
jerry_value_free(updateFn);
}
// Calling instance.render()/update() (JS method-call syntax) only reaches
// native code if the property lookup actually resolves to
// UIElement.prototype's own (native) method -- if a (sub)class overrides
// it, that override shadows it and callers bypass uiElementRender()/
// uiElementUpdate() entirely, which is what actually tracks parent/
// worldX/worldY (for render) or reaches this element at all from the
// engine's per-tick/per-frame root walk (for update). So if this
// instance's resolved method is anything other than the base one, stash
// the override under stashProp and shadow methodName itself (as an own
// property, just on this instance) with trampoline --
// moduleUiElementRenderScripted/moduleUiElementUpdateScripted then call
// the stashed override from inside uiElementRender()/uiElementUpdate(),
// fully wired up.
static void moduleUiElementWrapUserMethodIfOverridden(
const jerry_value_t thisValue,
const char_t *methodName,
const char_t *stashProp,
jerry_external_handler_t trampolineFn
) {
jerry_value_t resolvedRender = moduleBaseGetProp(thisValue, "render");
jerry_value_t baseRender = moduleBaseGetProp(
MODULE_UI_ELEMENT_PROTO.prototype, "render"
jerry_value_t resolvedMethod = moduleBaseGetProp(thisValue, methodName);
jerry_value_t baseMethod = moduleBaseGetProp(
MODULE_UI_ELEMENT_PROTO.prototype, methodName
);
if(
jerry_value_is_function(resolvedRender) &&
!moduleUiElementStrictEqual(resolvedRender, baseRender)
jerry_value_is_function(resolvedMethod) &&
!moduleUiElementStrictEqual(resolvedMethod, baseMethod)
) {
jerry_value_t userRenderKey = jerry_string_sz(
MODULE_UI_ELEMENT_USER_RENDER_PROP
);
jerry_object_set(thisValue, userRenderKey, resolvedRender);
jerry_value_free(userRenderKey);
jerry_value_t stashKey = jerry_string_sz(stashProp);
jerry_object_set(thisValue, stashKey, resolvedMethod);
jerry_value_free(stashKey);
jerry_value_t trampoline = jerry_function_external(
moduleUiElementRenderMethod
);
jerry_value_t renderKey = jerry_string_sz("render");
jerry_object_set(thisValue, renderKey, trampoline);
jerry_value_free(renderKey);
jerry_value_t trampoline = jerry_function_external(trampolineFn);
jerry_value_t methodKey = jerry_string_sz(methodName);
jerry_object_set(thisValue, methodKey, trampoline);
jerry_value_free(methodKey);
jerry_value_free(trampoline);
}
jerry_value_free(baseRender);
jerry_value_free(resolvedRender);
jerry_value_free(baseMethod);
jerry_value_free(resolvedMethod);
}
errorret_t moduleUiElementConstructShared(
@@ -119,7 +149,14 @@ errorret_t moduleUiElementConstructShared(
thisValue, &MODULE_UI_ELEMENT_PROTO.info, inst
);
UI_ELEMENT_JS_INSTANCES[inst->id] = jerry_value_copy(thisValue);
moduleUiElementWrapUserRenderIfOverridden(thisValue);
moduleUiElementWrapUserMethodIfOverridden(
thisValue, "render", MODULE_UI_ELEMENT_USER_RENDER_PROP,
moduleUiElementRenderMethod
);
moduleUiElementWrapUserMethodIfOverridden(
thisValue, "update", MODULE_UI_ELEMENT_USER_UPDATE_PROP,
moduleUiElementUpdateMethod
);
jerry_value_t initFn = moduleBaseGetProp(thisValue, "init");
if(jerry_value_is_function(initFn)) {
@@ -259,6 +296,18 @@ moduleBaseFunction(moduleUiElementRenderMethod) {
return jerry_undefined();
}
moduleBaseFunction(moduleUiElementUpdateChildrenMethod) {
moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet);
uiElementUpdateChildren(&UI_ELEMENTS[h->id]);
return jerry_undefined();
}
moduleBaseFunction(moduleUiElementUpdateMethod) {
moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet);
uiElementUpdate(h->id);
return jerry_undefined();
}
// Frees the registered JS instance for elementId and every descendant
// (walked via the still-intact children[]/childCount), so the registry
// never points at an about-to-be-disposed id. Must run BEFORE
@@ -342,12 +391,19 @@ void moduleUiElementInit(void) {
scriptProtoDefineFunc(
&MODULE_UI_ELEMENT_PROTO, "render", moduleUiElementRenderMethod
);
scriptProtoDefineFunc(
&MODULE_UI_ELEMENT_PROTO, "updateChildren", moduleUiElementUpdateChildrenMethod
);
scriptProtoDefineFunc(
&MODULE_UI_ELEMENT_PROTO, "update", moduleUiElementUpdateMethod
);
scriptProtoDefineFunc(
&MODULE_UI_ELEMENT_PROTO, "dispose", moduleUiElementDisposeMethod
);
UI_ELEMENT_CALLBACKS[UI_ELEMENT_TYPE_SCRIPTED] = (uilelementcallbacks_t){
.render = moduleUiElementRenderScripted
.render = moduleUiElementRenderScripted,
.update = moduleUiElementUpdateScripted
};
}
+6 -5
View File
@@ -30,11 +30,12 @@ extern scriptproto_t MODULE_UI_ELEMENT_PROTO;
* cache, just cheap to recompute), parent (read-only, the UIElement this
* one was add()ed to, or undefined), childCount (read-only), add(child)/
* remove(child) (persistent parent/child links, up to
* UI_ELEMENT_CHILDREN_MAX per element), render(), renderChildren()
* (renders whatever was added, in add-order -- called automatically by
* render() unless a subclass overrides render(), in which case the
* override has full manual control and must call this itself if it
* still wants added children drawn), and dispose() (cascades to every
* UI_ELEMENT_CHILDREN_MAX per element), render()/update(),
* renderChildren()/updateChildren() (render/update whatever was added,
* in add-order -- called automatically by render()/update() unless a
* subclass overrides it, in which case the override has full manual
* control and must call the *Children() twin itself if it still wants
* added children rendered/updated), and dispose() (cascades to every
* descendant).
*/
void moduleUiElementInit(void);
+36 -1
View File
@@ -35,10 +35,30 @@ void uiRootRemove(const uielementid_t elementId) {
}
}
bool_t uiOverlayRootAdd(const uielementid_t elementId) {
for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) {
if(UI.overlayRoot[i] != UI_ELEMENT_ID_INVALID) continue;
UI.overlayRoot[i] = elementId;
return true;
}
return false;
}
void uiOverlayRootRemove(const uielementid_t elementId) {
for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) {
if(UI.overlayRoot[i] != elementId) continue;
UI.overlayRoot[i] = UI_ELEMENT_ID_INVALID;
return;
}
}
errorret_t uiInit(void) {
memoryZero(UI_ELEMENTS, sizeof(UI_ELEMENTS));
memoryZero(&UI, sizeof(UI));
for(uint8_t i = 0; i < UI_ROOT_MAX; i++) UI.root[i] = UI_ELEMENT_ID_INVALID;
for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) {
UI.overlayRoot[i] = UI_ELEMENT_ID_INVALID;
}
consolePrint(
"UI elements size: %zu bytes (%.2f KB), %zu bytes/element, "
@@ -56,7 +76,15 @@ errorret_t uiUpdate(void) {
if(TIME.dynamicUpdate) errorOk();
#endif
for(uint8_t i = 0; i < UI_ROOT_MAX; i++) {
if(UI.root[i] == UI_ELEMENT_ID_INVALID) continue;
uiElementUpdate(UI.root[i]);
}
for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) {
if(UI.overlayRoot[i] == UI_ELEMENT_ID_INVALID) continue;
uiElementUpdate(UI.overlayRoot[i]);
}
errorOk();
}
@@ -99,6 +127,13 @@ errorret_t uiRender(void) {
uiElementRender(UI.root[i]);
}
// Always drawn after every UI.root[] element, regardless of add order
// between the two arrays -- debug overlays/screen fades stay on top.
for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) {
if(UI.overlayRoot[i] == UI_ELEMENT_ID_INVALID) continue;
uiElementRender(UI.overlayRoot[i]);
}
errorChain(spriteBatchFlush());
errorOk();
}
+24
View File
@@ -10,9 +10,16 @@
#include "uielement.h"
#define UI_ROOT_MAX 32
#define UI_OVERLAY_ROOT_MAX 8
typedef struct {
uielementid_t root[UI_ROOT_MAX];
// Drawn/updated strictly after every UI.root[] element, regardless of
// add order between the two arrays -- for things that must always sit
// on top (debug overlays, screen fades/transitions), so they don't
// have to race normal UI content for root slots/order.
uielementid_t overlayRoot[UI_OVERLAY_ROOT_MAX];
} ui_t;
extern ui_t UI;
@@ -33,6 +40,23 @@ bool_t uiRootAdd(const uielementid_t elementId);
*/
void uiRootRemove(const uielementid_t elementId);
/**
* Adds an element to UI's overlay roots (drawn/updated after every
* UI.root[] element), in the first free slot.
*
* @param elementId The element id to add as an overlay root.
* @return True on success, false if UI.overlayRoot[] is already full.
*/
bool_t uiOverlayRootAdd(const uielementid_t elementId);
/**
* Removes an element from UI's overlay roots, if present. No-op if
* elementId isn't currently an overlay root.
*
* @param elementId The element id to remove.
*/
void uiOverlayRootRemove(const uielementid_t elementId);
/**
* Initializes the UI system.
*
+24 -1
View File
@@ -25,6 +25,8 @@ uielement_t UI_ELEMENTS[UI_ELEMENT_COUNT_MAX];
#define UI_ELEMENT_RENDER_DEPTH_MAX 16
static uint8_t UI_ELEMENT_RENDER_DEPTH = 0;
static void uiElementRenderPrepared(uielement_t *element);
uilelementcallbacks_t UI_ELEMENT_CALLBACKS[UI_ELEMENT_TYPE_COUNT] = {
[UI_ELEMENT_TYPE_NULL] = {0},
@@ -80,6 +82,14 @@ void uiElementUpdate(const uielementid_t elementId) {
}
}
void uiElementUpdateChildren(uielement_t *element) {
assertNotNull(element, "Element is null");
for(uint8_t i = 0; i < element->childCount; i++) {
uiElementUpdate(element->children[i]);
}
}
void uiElementRender(const uielementid_t elementId) {
assertTrue(elementId < UI_ELEMENT_COUNT_MAX, "Invalid ID");
@@ -87,7 +97,16 @@ void uiElementRender(const uielementid_t elementId) {
assertTrue(element->type != UI_ELEMENT_TYPE_NULL, "Element is null type");
uiElementUpdateWorld(element);
uiElementRenderPrepared(element);
}
// Shared depth-guarded dispatch to the type's render callback, once
// worldX/worldY are already known-fresh. uiElementRender() gets there via
// a full uiElementUpdateWorld() walk (parent freshness unknown); child
// elements get there via uiElementRenderChildren(), which derives world
// position in O(1) from the parent it was just called on -- avoiding a
// second walk back up the same chain that's already mid-render.
static void uiElementRenderPrepared(uielement_t *element) {
assertTrue(
UI_ELEMENT_RENDER_DEPTH < UI_ELEMENT_RENDER_DEPTH_MAX,
"UI render depth exceeded -- elements nested too deep"
@@ -120,7 +139,10 @@ void uiElementRenderChildren(uielement_t *element) {
assertNotNull(element, "Element is null");
for(uint8_t i = 0; i < element->childCount; i++) {
uiElementRender(element->children[i]);
uielement_t *child = &UI_ELEMENTS[element->children[i]];
child->worldX = child->x + element->worldX;
child->worldY = child->y + element->worldY;
uiElementRenderPrepared(child);
}
}
@@ -201,6 +223,7 @@ void uiElementDispose(const uielementid_t elementId) {
uiElementSetParent(elementId, UI_ELEMENT_ID_INVALID);
uiRootRemove(elementId);
uiOverlayRootRemove(elementId);
for(uint8_t i = 0; i < element->childCount; i++) {
UI_ELEMENTS[element->children[i]].parent = UI_ELEMENT_ID_INVALID;
+11
View File
@@ -106,6 +106,17 @@ void uiElementUpdateWorld(uielement_t *element);
*/
void uiElementRenderChildren(uielement_t *element);
/**
* Updates every child of element. Called unconditionally by native leaf
* update callbacks (uiLabelUpdate, uiRectangleUpdate) since they have no
* override concept; called by UI_ELEMENT_TYPE_SCRIPTED's update callback
* only when the instance did NOT override update() -- mirrors
* uiElementRenderChildren/render() exactly.
*
* @param element The UI element whose children to update.
*/
void uiElementUpdateChildren(uielement_t *element);
/**
* Sets the persistent parent of a UI element, detaching it from any
* current parent first. Pass UI_ELEMENT_ID_INVALID to detach only.
+10
View File
@@ -36,8 +36,18 @@ void uiLabelSetText(const uielementid_t elementId, const char_t *text) {
);
}
void uiLabelSetColor(const uielementid_t elementId, const color_t color) {
assertTrue(elementId < UI_ELEMENT_COUNT_MAX, "Invalid ID");
uielement_t *element = &UI_ELEMENTS[elementId];
assertTrue(element->type == UI_ELEMENT_TYPE_LABEL, "Element is not a label");
element->label.color = color;
}
void uiLabelUpdate(uielement_t *element) {
assertNotNull(element, "Element cannot be NULL");
uiElementUpdateChildren(element);
}
void uiLabelRender(uielement_t *element) {
+8
View File
@@ -40,6 +40,14 @@ void uiLabelInit(uielement_t *element);
*/
void uiLabelSetText(const uielementid_t elementId, const char_t *text);
/**
* Sets the label's text color.
*
* @param elementId The ID of the UI element to update as a label.
* @param color The new text color.
*/
void uiLabelSetColor(const uielementid_t elementId, const color_t color);
/**
* Updates a UI label element.
*
+1
View File
@@ -50,6 +50,7 @@ void uiRectangleSetColor(const uielementid_t elementId, const color_t color) {
void uiRectangleUpdate(uielement_t *element) {
assertNotNull(element, "Element cannot be NULL");
uiElementUpdateChildren(element);
}
void uiRectangleRender(uielement_t *element) {
+8
View File
@@ -13,6 +13,14 @@ dusktest(test_moduleconsole.c)
dusktest(test_moduleplatform.c)
dusktest(test_moduletime.c)
dusktest(test_modulescreen.c)
dusktest(test_modulelocale.c)
dusktest(test_modulesave.c)
dusktest(test_moduleinput.c)
dusktest(test_scriptinput.c)
+70
View File
@@ -8,6 +8,7 @@
#include "dusktest.h"
#include "console/console.h"
#include "script/scriptmanager.h"
#include "script/module/modulebase.h"
#include "util/memory.h"
static int console_setup(void **state) {
@@ -53,6 +54,59 @@ static void test_console_print_no_args_prints_empty_line(void **state) {
assert_int_equal(memoryGetAllocatedCount(), 0);
}
static void test_console_line_count_is_history_max(void **state) {
jerry_value_t result;
errorret_t ret = scriptManagerExec("Console.lineCount;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), CONSOLE_HISTORY_MAX);
jerry_value_free(result);
}
static void test_console_get_line_reads_history(void **state) {
jerry_value_t result;
errorret_t ret = scriptManagerExec(
"Console.print('a line'); Console.getLine(Console.lineCount - 1);",
&result
);
assert_true(errorIsOk(ret));
char_t buf[32];
moduleBaseToString(result, buf, sizeof(buf));
assert_string_equal(buf, "a line");
jerry_value_free(result);
}
static void test_console_get_line_out_of_range_throws(void **state) {
errorret_t ret = scriptManagerExec("Console.getLine(999);", NULL);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_console_visible_defaults_false(void **state) {
jerry_value_t result;
errorret_t ret = scriptManagerExec("Console.visible;", &result);
assert_true(errorIsOk(ret));
assert_false(jerry_value_is_true(result));
jerry_value_free(result);
}
static void test_console_consume_dirty_clears_flag(void **state) {
CONSOLE.dirty = true;
jerry_value_t result;
errorret_t ret = scriptManagerExec("Console.consumeDirty();", &result);
assert_true(errorIsOk(ret));
assert_true(jerry_value_is_true(result));
jerry_value_free(result);
assert_false(CONSOLE.dirty);
ret = scriptManagerExec("Console.consumeDirty();", &result);
assert_true(errorIsOk(ret));
assert_false(jerry_value_is_true(result));
jerry_value_free(result);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
@@ -64,6 +118,22 @@ int main(void) {
test_console_print_no_args_prints_empty_line,
console_setup, console_teardown
),
cmocka_unit_test_setup_teardown(
test_console_line_count_is_history_max, console_setup, console_teardown
),
cmocka_unit_test_setup_teardown(
test_console_get_line_reads_history, console_setup, console_teardown
),
cmocka_unit_test_setup_teardown(
test_console_get_line_out_of_range_throws,
console_setup, console_teardown
),
cmocka_unit_test_setup_teardown(
test_console_visible_defaults_false, console_setup, console_teardown
),
cmocka_unit_test_setup_teardown(
test_console_consume_dirty_clears_flag, console_setup, console_teardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+17
View File
@@ -122,12 +122,29 @@ static void test_input_query_functions_default_to_zero(void **state) {
jerry_value_free(result);
}
static void test_input_deadzone_readback(void **state) {
jerry_value_t result;
errorret_t ret = scriptManagerExec("Input.deadzone = 0.25;", &result);
assert_true(errorIsOk(ret));
jerry_value_free(result);
assert_float_equal(INPUT.deadzone, 0.25f, 0.0001f);
ret = scriptManagerExec("Input.deadzone;", &result);
assert_true(errorIsOk(ret));
assert_float_equal((float_t)jerry_value_as_number(result), 0.25f, 0.0001f);
jerry_value_free(result);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(
test_inputbind_exposes_action_ordinals, input_setup, input_teardown
),
cmocka_unit_test_setup_teardown(
test_input_deadzone_readback, input_setup, input_teardown
),
cmocka_unit_test_setup_teardown(
test_inputbutton_exposes_platform_button_names,
input_setup, input_teardown
+70
View File
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "script/scriptmanager.h"
#include "locale/localemanager.h"
static int locale_setup(void **state) {
errorret_t ret = scriptManagerInit();
if(errorIsNotOk(ret)) { errorCatch(ret); return -1; }
return 0;
}
static int locale_teardown(void **state) {
errorret_t ret = scriptManagerDispose();
if(errorIsNotOk(ret)) errorCatch(ret);
return 0;
}
// Only the binding shape is covered here -- localeManagerInit() was never
// called (LOCALE.locale stays NULL, as a fresh process leaves it), and an
// actual successful Locale.set() requires the real asset pipeline (a
// mounted locale/*.po file) which the main Dusk binary's headless run
// exercises instead; a plain module test isn't the place to stand up the
// full asset system just for this.
static void test_locale_current_defaults_to_empty_string(void **state) {
jerry_value_t result;
errorret_t ret = scriptManagerExec("Locale.current;", &result);
assert_true(errorIsOk(ret));
char_t buf[8];
jerry_string_to_buffer(
result, JERRY_ENCODING_UTF8, (jerry_char_t *)buf, sizeof(buf) - 1
);
jerry_value_free(result);
}
static void test_locale_set_unknown_name_throws(void **state) {
errorret_t ret = scriptManagerExec("Locale.set('xx-XX');", NULL);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_locale_set_requires_a_string(void **state) {
errorret_t ret = scriptManagerExec("Locale.set(42);", NULL);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(
test_locale_current_defaults_to_empty_string,
locale_setup, locale_teardown
),
cmocka_unit_test_setup_teardown(
test_locale_set_unknown_name_throws, locale_setup, locale_teardown
),
cmocka_unit_test_setup_teardown(
test_locale_set_requires_a_string, locale_setup, locale_teardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "script/scriptmanager.h"
#include "save/save.h"
#include "save/savesettings.h"
#include "input/input.h"
static int save_setup(void **state) {
// Writes under ./saves relative to this test binary's own working
// directory (SAVE_LINUX_PATH's default) -- a build artifact, not real
// user save data.
errorret_t ret = saveInit();
if(errorIsNotOk(ret)) { errorCatch(ret); return -1; }
ret = scriptManagerInit();
if(errorIsNotOk(ret)) { errorCatch(ret); return -1; }
return 0;
}
static int save_teardown(void **state) {
errorret_t ret = scriptManagerDispose();
if(errorIsNotOk(ret)) errorCatch(ret);
ret = saveDispose();
if(errorIsNotOk(ret)) errorCatch(ret);
return 0;
}
static void test_save_write_persists_settings(void **state) {
INPUT.deadzone = 0.42f;
jerry_value_t result;
errorret_t ret = scriptManagerExec("Save.write();", &result);
assert_true(errorIsOk(ret));
jerry_value_free(result);
INPUT.deadzone = 0.0f;
ret = saveSettingsLoad();
assert_true(errorIsOk(ret));
assert_float_equal(INPUT.deadzone, 0.42f, 0.0001f);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(
test_save_write_persists_settings, save_setup, save_teardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "script/scriptmanager.h"
#include "display/screen/screen.h"
static int screen_setup(void **state) {
errorret_t ret = scriptManagerInit();
if(errorIsNotOk(ret)) { errorCatch(ret); return -1; }
return 0;
}
static int screen_teardown(void **state) {
errorret_t ret = scriptManagerDispose();
if(errorIsNotOk(ret)) errorCatch(ret);
return 0;
}
static void test_screen_dimensions_readback(void **state) {
SCREEN.width = 640;
SCREEN.height = 480;
SCREEN.scanX = 10;
SCREEN.scanY = 20;
SCREEN.scanWidth = 600;
SCREEN.scanHeight = 440;
jerry_value_t result;
errorret_t ret = scriptManagerExec("Screen.width;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), 640);
jerry_value_free(result);
ret = scriptManagerExec("Screen.height;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), 480);
jerry_value_free(result);
ret = scriptManagerExec("Screen.scanX;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), 10);
jerry_value_free(result);
ret = scriptManagerExec("Screen.scanY;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), 20);
jerry_value_free(result);
ret = scriptManagerExec("Screen.scanWidth;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), 600);
jerry_value_free(result);
ret = scriptManagerExec("Screen.scanHeight;", &result);
assert_true(errorIsOk(ret));
assert_int_equal((int32_t)jerry_value_as_number(result), 440);
jerry_value_free(result);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(
test_screen_dimensions_readback, screen_setup, screen_teardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "script/scriptmanager.h"
#include "time/time.h"
static int time_setup(void **state) {
errorret_t ret = scriptManagerInit();
if(errorIsNotOk(ret)) { errorCatch(ret); return -1; }
return 0;
}
static int time_teardown(void **state) {
errorret_t ret = scriptManagerDispose();
if(errorIsNotOk(ret)) errorCatch(ret);
return 0;
}
static void test_time_delta_and_time_readback(void **state) {
TIME.delta = 0.5f;
TIME.time = 12.0f;
jerry_value_t result;
errorret_t ret = scriptManagerExec("Time.delta;", &result);
assert_true(errorIsOk(ret));
assert_float_equal((float_t)jerry_value_as_number(result), 0.5f, 0.0001f);
jerry_value_free(result);
ret = scriptManagerExec("Time.time;", &result);
assert_true(errorIsOk(ret));
assert_float_equal((float_t)jerry_value_as_number(result), 12.0f, 0.0001f);
jerry_value_free(result);
}
static void test_time_render_delta_readback(void **state) {
// Under DUSK_TIME_DYNAMIC, renderDelta reads dynamicDelta (real
// per-render-frame time); otherwise it falls back to the fixed delta.
// Set both so this test passes either way this binary was built.
TIME.delta = 0.25f;
#ifdef DUSK_TIME_DYNAMIC
TIME.dynamicDelta = 0.75f;
#endif
jerry_value_t result;
errorret_t ret = scriptManagerExec("Time.renderDelta;", &result);
assert_true(errorIsOk(ret));
#ifdef DUSK_TIME_DYNAMIC
assert_float_equal((float_t)jerry_value_as_number(result), 0.75f, 0.0001f);
#else
assert_float_equal((float_t)jerry_value_as_number(result), 0.25f, 0.0001f);
#endif
jerry_value_free(result);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(
test_time_delta_and_time_readback, time_setup, time_teardown
),
cmocka_unit_test_setup_teardown(
test_time_render_delta_readback, time_setup, time_teardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+84
View File
@@ -17,6 +17,9 @@ static int ui_setup(void **state) {
memoryZero(UI_ELEMENTS, sizeof(UI_ELEMENTS));
memoryZero(&UI, sizeof(UI));
for(uint8_t i = 0; i < UI_ROOT_MAX; i++) UI.root[i] = UI_ELEMENT_ID_INVALID;
for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) {
UI.overlayRoot[i] = UI_ELEMENT_ID_INVALID;
}
errorret_t ret = scriptManagerInit();
if(errorIsNotOk(ret)) { errorCatch(ret); return -1; }
@@ -32,6 +35,17 @@ static int ui_teardown(void **state) {
return 0;
}
static void exec(const char_t *script) {
jerry_value_t result;
errorret_t ret = scriptManagerExec(script, &result);
if(errorIsNotOk(ret)) {
errorCatch(errorPrint(ret));
fail_msg("Script execution failed (see printed error above)");
}
assert_true(errorIsOk(ret));
jerry_value_free(result);
}
static void test_ui_add_requires_an_argument(void **state) {
errorret_t ret = scriptManagerExec("UI.add();", NULL);
assert_true(errorIsNotOk(ret));
@@ -65,6 +79,64 @@ static void test_ui_remove_of_untracked_element_is_a_noop(void **state) {
jerry_value_free(result);
}
static void test_ui_add_overlay_and_remove_overlay(void **state) {
assert_true(UI.overlayRoot[0] == UI_ELEMENT_ID_INVALID);
exec(
"globalThis.el = new UIElement();"
"el.x = 3;"
"UI.addOverlay(el);"
);
assert_true(UI.overlayRoot[0] != UI_ELEMENT_ID_INVALID);
assert_true(UI_ELEMENTS[UI.overlayRoot[0]].type == UI_ELEMENT_TYPE_SCRIPTED);
assert_float_equal(UI_ELEMENTS[UI.overlayRoot[0]].x, 3.0f, 0.001f);
exec("UI.removeOverlay(el);");
assert_true(UI.overlayRoot[0] == UI_ELEMENT_ID_INVALID);
}
static void test_ui_add_overlay_detaches_from_parent(void **state) {
exec(
"globalThis.parent = new UIElement();"
"globalThis.child = new UIElement();"
"parent.add(child);"
"if(parent.childCount !== 1) throw new Error('expected 1 child');"
"UI.addOverlay(child);"
"if(parent.childCount !== 0) {"
" throw new Error('expected 0 children after UI.addOverlay');"
"}"
"if(child.parent !== undefined) throw new Error('child.parent should be undefined');"
);
}
static void test_ui_dispose_removes_overlay_root(void **state) {
exec(
"globalThis.el = new UIElement();"
"UI.addOverlay(el);"
);
assert_true(UI.overlayRoot[0] != UI_ELEMENT_ID_INVALID);
exec("el.dispose();");
assert_true(UI.overlayRoot[0] == UI_ELEMENT_ID_INVALID);
}
static void test_ui_overlay_root_is_independent_of_root(void **state) {
// A normal root and an overlay root are two separate arrays -- adding
// to one must never touch the other.
exec(
"globalThis.a = new UIElement();"
"globalThis.b = new UIElement();"
"UI.add(a);"
"UI.addOverlay(b);"
);
assert_true(UI.root[0] != UI_ELEMENT_ID_INVALID);
assert_true(UI.overlayRoot[0] != UI_ELEMENT_ID_INVALID);
assert_true(UI.root[0] != UI.overlayRoot[0]);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
@@ -80,6 +152,18 @@ int main(void) {
cmocka_unit_test_setup_teardown(
test_ui_remove_of_untracked_element_is_a_noop, ui_setup, ui_teardown
),
cmocka_unit_test_setup_teardown(
test_ui_add_overlay_and_remove_overlay, ui_setup, ui_teardown
),
cmocka_unit_test_setup_teardown(
test_ui_add_overlay_detaches_from_parent, ui_setup, ui_teardown
),
cmocka_unit_test_setup_teardown(
test_ui_dispose_removes_overlay_root, ui_setup, ui_teardown
),
cmocka_unit_test_setup_teardown(
test_ui_overlay_root_is_independent_of_root, ui_setup, ui_teardown
),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+128
View File
@@ -173,6 +173,96 @@ static void test_uielement_render_children_explicit_opt_in(void **state) {
);
}
static void test_uielement_update_no_override_is_noop_when_childless(
void **state
) {
// No override, no children -- update() should just do nothing, safely.
exec("new UIElement().update();");
}
static void test_uielement_update_dispatches_to_js_override(void **state) {
exec(
"class Foo extends UIElement {"
" update() { this.updated = true; }"
"}"
"var foo = new Foo();"
"foo.update();"
"if(foo.updated !== true) throw new Error('update() override not called');"
);
}
static void test_uielement_no_override_auto_updates_children(void **state) {
exec(
"class Child extends UIElement {"
" update() { this.updated = true; }"
"}"
"var parent = new UIElement();"
"var child = new Child();"
"parent.add(child);"
"parent.update();"
"if(child.updated !== true) throw new Error('child was not auto-updated');"
);
}
static void test_uielement_override_does_not_auto_update_children(
void **state
) {
exec(
"class Child extends UIElement {"
" update() { this.updated = true; }"
"}"
"class Parent extends UIElement {"
" update() { this.overrideCalled = true; }"
"}"
"var parent = new Parent();"
"var child = new Child();"
"parent.add(child);"
"parent.update();"
"if(parent.overrideCalled !== true) throw new Error('override not called');"
"if(child.updated === true) {"
" throw new Error('child should not auto-update under an override');"
"}"
);
}
static void test_uielement_update_children_explicit_opt_in(void **state) {
exec(
"class Child extends UIElement {"
" update() { this.updated = true; }"
"}"
"class Parent extends UIElement {"
" update() { this.updateChildren(); }"
"}"
"var parent = new Parent();"
"var child = new Child();"
"parent.add(child);"
"parent.update();"
"if(child.updated !== true) throw new Error('updateChildren() did not update child');"
);
}
static void test_ui_update_reaches_root_and_children(void **state) {
exec(
"globalThis.parent = new UIElement();"
"globalThis.child = new UIElement();"
"class Grandchild extends UIElement {"
" update() { this.updated = true; }"
"}"
"globalThis.grandchild = new Grandchild();"
"parent.add(child);"
"child.add(grandchild);"
"UI.add(parent);"
);
errorCatch(errorPrint(uiUpdate()));
exec(
"if(grandchild.updated !== true) {"
" throw new Error('uiUpdate() did not reach a rooted grandchild');"
"}"
);
}
static void test_uielement_add_self_throws(void **state) {
execExpectError("var el = new UIElement(); el.add(el);");
}
@@ -252,6 +342,17 @@ static void test_label_text_default_and_set(void **state) {
);
}
static void test_label_color_readback(void **state) {
exec(
"var label = new Label();"
"label.color = { r: 10, g: 20, b: 30, a: 40 };"
"if(label.color.r !== 10 || label.color.g !== 20 ||"
" label.color.b !== 30 || label.color.a !== 40) {"
" throw new Error('color readback wrong');"
"}"
);
}
static void test_rectangle_size_and_color(void **state) {
exec(
"var rect = new Rectangle();"
@@ -332,6 +433,30 @@ int main(void) {
test_uielement_render_children_explicit_opt_in,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_uielement_update_no_override_is_noop_when_childless,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_uielement_update_dispatches_to_js_override,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_uielement_no_override_auto_updates_children,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_uielement_override_does_not_auto_update_children,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_uielement_update_children_explicit_opt_in,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_ui_update_reaches_root_and_children,
uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_uielement_add_self_throws, uielement_setup, uielement_teardown
),
@@ -355,6 +480,9 @@ int main(void) {
cmocka_unit_test_setup_teardown(
test_label_text_default_and_set, uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_label_color_readback, uielement_setup, uielement_teardown
),
cmocka_unit_test_setup_teardown(
test_rectangle_size_and_color, uielement_setup, uielement_teardown
),
+2
View File
@@ -14,4 +14,6 @@ declare class Label extends UIElement {
/** Display text. Rebuilds the label's glyph cache on assignment. */
text: string;
/** Text color, 0-255 per channel. */
color: { r: number; g: number; b: number; a: number };
}
+15
View File
@@ -99,6 +99,21 @@ declare class UIElement {
* overridden render(). */
renderChildren(): void;
/**
* Called once per tick. The base implementation calls updateChildren()
* and nothing else. Override it for per-tick logic (e.g. polling
* input) -- an override does NOT get its children auto-updated; call
* updateChildren() yourself if you still want them updated. Not called
* automatically by the engine unless this element is a render root
* (added via UI.add()) or a descendant of one.
*/
update(): void;
/** Updates every added child, in add-order. Called automatically by
* the base update() -- only needed explicitly from inside an
* overridden update(). */
updateChildren(): void;
/** Releases this element's pool slot and cascades to every descendant
* (each disposed the same way, recursively). Also removes it from
* UI's render roots if it was added there. Safe to call more than