From da08cc9a5c2bc9ae9b7d162c388358d208a14444 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 27 Aug 2026 21:23:27 +0200 Subject: [PATCH 1/6] feat(ui): give UI style values a skin-shaped theme type A theme is a skin, not a colour table: per role and per state it carries a background descriptor, a text style and insets. Insets are layout input, which is why a later style setter has to invalidate layout as well as paint. The background descriptor covers what the widgets draw today (a rounded fill with an optional border) and the nine-slice case the texture-based widget suite needs, so the value type does not have to change again when skins become textured. `defaultUITheme` reproduces the widgets' current look exactly and is frozen in development builds, so a caller who mutates a shared skin fails at the write instead of silently restyling every widget that inherited it. --- src/ui/theme.ts | 225 ++++++++++++++++++++++++++++++++++++++++++ test/ui/theme.test.ts | 73 ++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 src/ui/theme.ts create mode 100644 test/ui/theme.test.ts diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 000000000..b12f3b5ab --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,225 @@ +import { Color } from '#core/Color'; +import type { NineSliceInsets, NineSliceModes } from '#rendering/sprite/nineSlice'; +import type { TextStyleOptions } from '#rendering/text/TextStyle'; +import type { Texture } from '#rendering/texture/Texture'; +import type { TextureRegion } from '#rendering/texture/TextureRegion'; + +/** + * Per-edge pixel insets. A skin's insets are layout input, not decoration: they + * describe the content box a widget positions its content in, so changing them + * re-lays out the widget rather than only repainting it. + */ +export interface UIInsets { + readonly left: number; + readonly top: number; + readonly right: number; + readonly bottom: number; +} + +/** + * Visual state a widget paints in. A widget that tracks no interaction stays on + * `'normal'`; states a skin set leaves undefined fall back to `'normal'`. + */ +export type UIWidgetState = 'normal' | 'hover' | 'pressed' | 'disabled' | 'focused'; + +/** Widget surface that paints nothing - the whole node is its content. */ +export interface UINoBackground { + readonly kind: 'none'; +} + +/** Vector background: a rounded rectangle with an optional stroked border. */ +export interface UIFillBackground { + readonly kind: 'fill'; + readonly color: Color; + readonly borderColor: Color; + /** Border thickness in pixels; `0` draws no border. */ + readonly borderWidth: number; + /** Corner radius in pixels, clamped to half the widget's smaller side. */ + readonly cornerRadius: number; +} + +/** + * Textured background drawn as a nine-slice, so corners stay pixel-perfect + * while edges and center fill the widget's layout size. + */ +export interface UINineSliceBackground { + readonly kind: 'nineSlice'; + readonly texture: Texture | TextureRegion; + /** Source-texture slice widths, in texels. */ + readonly slices: number | Partial; + /** Destination border widths; defaults to `slices` (1:1 corners). */ + readonly border?: number | Partial; + readonly modes?: NineSliceModes; +} + +/** How a widget paints its body. */ +export type UIBackground = UINoBackground | UIFillBackground | UINineSliceBackground; + +/** + * The look of one widget surface in one state: what it paints, how its text is + * styled, and the content box its layout works against. + */ +export interface UISkin { + readonly background: UIBackground; + readonly text: TextStyleOptions; + readonly insets: UIInsets; +} + +/** + * One skin per state. Only `normal` is required - {@link resolveUISkin} falls + * back to it for every state a set does not define. + */ +export type UISkinSet = { readonly normal: UISkin } & { readonly [State in Exclude]?: UISkin }; + +/** + * A themed surface. Roles are per painted surface, not per widget class: a + * progress bar draws its track and its fill from two independent roles. + */ +export type UIThemeRole = 'panel' | 'button' | 'label' | 'progressBarTrack' | 'progressBarFill'; + +/** Skins for every role, as resolved for a widget. */ +export type UITheme = { readonly [Role in UIThemeRole]: UISkinSet }; + +/** Skin fields to override; an omitted field keeps the inherited one. */ +export type UISkinPatch = Partial; + +/** Per-state skin overrides. */ +export type UISkinSetPatch = { readonly [State in UIWidgetState]?: UISkinPatch }; + +/** + * Theme overrides applied on top of an inherited theme. Fields are replaced + * whole: naming `background` replaces the entire descriptor, since blending + * half a fill into a nine-slice has no meaning. Single-property tweaks belong + * on the widget (`panel.color = ...`), not in a theme patch. + */ +export type UIThemePatch = { readonly [Role in UIThemeRole]?: UISkinSetPatch }; + +const zeroInsets: UIInsets = { left: 0, top: 0, right: 0, bottom: 0 }; + +const noBackground: UINoBackground = { kind: 'none' }; + +const fill = (color: Color, cornerRadius: number, borderColor: Color = new Color(255, 255, 255, 0), borderWidth = 0): UIFillBackground => ({ + kind: 'fill', + color, + borderColor, + borderWidth, + cornerRadius, +}); + +const skin = (background: UIBackground, text: TextStyleOptions = {}, insets: UIInsets = zeroInsets): UISkin => ({ background, text, insets }); + +const buttonText: TextStyleOptions = { fillColor: new Color(255, 255, 255, 1), fontSize: 16, align: 'center' }; + +/** + * The theme a UI layer uses until one is assigned. It reproduces the widgets' + * built-in look, so assigning a patch changes only what the patch names. + */ +export const defaultUITheme: UITheme = { + panel: { + normal: skin(fill(new Color(30, 34, 45, 0.92), 8, new Color(255, 255, 255, 0.12), 0)), + }, + button: { + normal: skin(fill(new Color(54, 120, 220, 1), 8), buttonText), + hover: skin(fill(new Color(74, 140, 240, 1), 8), buttonText), + pressed: skin(fill(new Color(40, 96, 180, 1), 8), buttonText), + disabled: skin(fill(new Color(70, 76, 90, 1), 8), buttonText), + }, + label: { + normal: skin(noBackground, { fillColor: new Color(255, 255, 255, 1), fontSize: 16 }), + }, + progressBarTrack: { + normal: skin(fill(new Color(255, 255, 255, 0.16), 4)), + }, + progressBarFill: { + normal: skin(fill(new Color(80, 220, 120, 1), 4)), + }, +}; + +/** The skin a state paints with, falling back to `normal` where undefined. */ +export const resolveUISkin = (set: UISkinSet, state: UIWidgetState): UISkin => set[state] ?? set.normal; + +const mergeSkin = (base: UISkin, patch: UISkinPatch): UISkin => ({ + background: patch.background ?? base.background, + text: patch.text ?? base.text, + insets: patch.insets ?? base.insets, +}); + +const mergeSkinSet = (base: UISkinSet, patch: UISkinSetPatch): UISkinSet => { + const merged: { normal: UISkin } & { [State in Exclude]?: UISkin } = { ...base }; + + for (const state of Object.keys(patch) as UIWidgetState[]) { + const skinPatch = patch[state]; + + if (skinPatch !== undefined) { + merged[state] = mergeSkin(resolveUISkin(base, state), skinPatch); + } + } + + return merged; +}; + +/** + * Resolve `patch` against `base`, role by role and state by state. Roles the + * patch does not name keep the base object itself, so an unchanged theme stays + * identical by reference - which is what makes a cascade refresh a pointer + * comparison rather than a deep diff. + */ +export const createUITheme = (patch: UIThemePatch, base: UITheme = defaultUITheme): UITheme => { + const roles = Object.keys(patch) as UIThemeRole[]; + + if (roles.length === 0) { + return base; + } + + const merged = { ...base }; + + for (const role of roles) { + const setPatch = patch[role]; + + if (setPatch !== undefined) { + merged[role] = mergeSkinSet(base[role], setPatch); + } + } + + return merged; +}; + +// Dev builds freeze the default theme so a caller who mutates a shared skin - +// or a `Color` inside one - fails at the write instead of silently restyling +// every widget that inherited it. `Color` fills its `_rgba` / `_array` caches +// lazily, so each one is warmed before freezing; an unwarmed frozen colour +// would throw on a plain read. +if (__DEV__) { + const freezeColor = (color: Color): void => { + color.toRgba8(); + color.toArray(); + Object.freeze(color); + }; + + const freezeBackground = (background: UIBackground): void => { + if (background.kind === 'fill') { + freezeColor(background.color); + freezeColor(background.borderColor); + } + + Object.freeze(background); + }; + + for (const set of Object.values(defaultUITheme)) { + for (const stateSkin of Object.values(set) as UISkin[]) { + freezeBackground(stateSkin.background); + + if (stateSkin.text.fillColor !== undefined) { + freezeColor(stateSkin.text.fillColor); + } + + Object.freeze(stateSkin.text); + Object.freeze(stateSkin.insets); + Object.freeze(stateSkin); + } + + Object.freeze(set); + } + + Object.freeze(defaultUITheme); +} diff --git a/test/ui/theme.test.ts b/test/ui/theme.test.ts new file mode 100644 index 000000000..2378e8ad5 --- /dev/null +++ b/test/ui/theme.test.ts @@ -0,0 +1,73 @@ +import { Color } from '#core/Color'; +import type { UIFillBackground, UISkinSet } from '#ui/theme'; +import { createUITheme, defaultUITheme, resolveUISkin } from '#ui/theme'; + +const fillOf = (set: UISkinSet, state: 'normal' | 'hover' | 'pressed' | 'disabled' | 'focused' = 'normal'): UIFillBackground => { + const background = resolveUISkin(set, state).background; + + if (background.kind !== 'fill') { + throw new Error(`expected a fill background, got '${background.kind}'`); + } + + return background; +}; + +describe('defaultUITheme', () => { + test('carries the widgets built-in look', () => { + expect(fillOf(defaultUITheme.panel).color.toRgba8()).toEqual(new Color(30, 34, 45, 0.92).toRgba8()); + expect(fillOf(defaultUITheme.button).color.toRgba8()).toEqual(new Color(54, 120, 220, 1).toRgba8()); + expect(fillOf(defaultUITheme.button, 'pressed').color.toRgba8()).toEqual(new Color(40, 96, 180, 1).toRgba8()); + expect(fillOf(defaultUITheme.progressBarFill).cornerRadius).toBe(4); + expect(defaultUITheme.label.normal.background.kind).toBe('none'); + }); + + test('is frozen in development builds, so a shared skin cannot be restyled by accident', () => { + expect(() => fillOf(defaultUITheme.panel).color.set(1, 2, 3, 1)).toThrow(); + expect(Object.isFrozen(defaultUITheme.panel.normal)).toBe(true); + }); +}); + +describe('resolveUISkin', () => { + test('falls back to the normal skin for states a set does not define', () => { + expect(resolveUISkin(defaultUITheme.panel, 'hover')).toBe(defaultUITheme.panel.normal); + expect(resolveUISkin(defaultUITheme.button, 'hover')).toBe(defaultUITheme.button.hover); + }); +}); + +describe('createUITheme', () => { + test('returns the base itself for an empty patch', () => { + expect(createUITheme({})).toBe(defaultUITheme); + }); + + test('keeps unpatched roles identical by reference', () => { + const theme = createUITheme({ panel: { normal: { insets: { left: 4, top: 4, right: 4, bottom: 4 } } } }); + + expect(theme.button).toBe(defaultUITheme.button); + expect(theme.panel).not.toBe(defaultUITheme.panel); + }); + + test('replaces named skin fields and keeps the rest', () => { + const background: UIFillBackground = { kind: 'fill', color: new Color(1, 2, 3, 1), borderColor: new Color(0, 0, 0, 1), borderWidth: 2, cornerRadius: 0 }; + const theme = createUITheme({ panel: { normal: { background } } }); + + expect(theme.panel.normal.background).toBe(background); + expect(theme.panel.normal.insets).toBe(defaultUITheme.panel.normal.insets); + }); + + test('derives a patched state from the normal skin when the base does not define it', () => { + const insets = { left: 6, top: 6, right: 6, bottom: 6 }; + const theme = createUITheme({ panel: { hover: { insets } } }); + + expect(theme.panel.hover?.insets).toBe(insets); + expect(theme.panel.hover?.background).toBe(defaultUITheme.panel.normal.background); + expect(theme.panel.normal).toBe(defaultUITheme.panel.normal); + }); + + test('resolves against an explicit base theme rather than the default', () => { + const dark = createUITheme({ label: { normal: { text: { fontSize: 24 } } } }); + const larger = createUITheme({ button: { normal: { text: { fontSize: 32 } } } }, dark); + + expect(larger.label.normal.text.fontSize).toBe(24); + expect(larger.button.normal.text.fontSize).toBe(32); + }); +}); From 3ddb5d52dfb2b5c11d871a179b1c43a42557aec0 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 27 Aug 2026 21:24:15 +0200 Subject: [PATCH 2/6] feat(ui): cascade the theme through the scene tree and split invalidation The theme travels the scene graph: `ThemedContainer` carries one for the subtree below it, `UIRoot` owns the layer's, and a widget resolves the nearest themed ancestor's theme with its own overrides applied on top. A widget without overrides reuses the inherited object by reference, so "did my theme change?" stays a pointer comparison, and reparenting re-resolves the same way `effectiveEnabled` already does. No global theme registry - two UI layers can carry different themes. Widgets now paint in `_repaint` and place content in `_relayout`, with `_invalidatePaint` and `_invalidateLayout` naming which of the two a change needs. A theme change takes the layout path: skin insets are layout input, so a paint-only refresh would be wrong as soon as a skin changes them. --- src/ui/ThemedContainer.ts | 66 ++++++++++++++ src/ui/UIRoot.ts | 33 ++++++- src/ui/Widget.ts | 126 +++++++++++++++++++++++--- test/ui/theme-cascade.test.ts | 160 ++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 src/ui/ThemedContainer.ts create mode 100644 test/ui/theme-cascade.test.ts diff --git a/src/ui/ThemedContainer.ts b/src/ui/ThemedContainer.ts new file mode 100644 index 000000000..941dcc11a --- /dev/null +++ b/src/ui/ThemedContainer.ts @@ -0,0 +1,66 @@ +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; + +import type { UITheme } from './theme'; +import { defaultUITheme } from './theme'; + +/** + * A container that carries a {@link UITheme} for the subtree below it. Widgets + * resolve their skins from the nearest themed ancestor, which is how a theme + * assigned on a {@link UIRoot} reaches every widget in that layer without a + * global registry. + */ +export abstract class ThemedContainer extends Container { + protected _theme: UITheme = defaultUITheme; + + /** The theme in effect for this node and everything below it. */ + public get theme(): UITheme { + return this._theme; + } + + /** + * @internal - re-resolve this node's theme against its ancestor chain. The + * cascade calls this on every themed node below a change; `force` re-resolves + * even when the inherited theme is unchanged, which is what a change to the + * node's own overrides needs. + */ + public abstract _refreshTheme(force?: boolean): void; + + /** The nearest themed ancestor's theme, or the built-in default when this node has none. */ + protected _resolveInheritedTheme(): UITheme { + for (let current = this.parent; current !== null; current = current.parent) { + if (current instanceof ThemedContainer) { + return current.theme; + } + } + + return defaultUITheme; + } + + /** Push a theme refresh into every themed descendant. */ + protected _cascadeTheme(): void { + for (const child of this.children) { + cascadeThemeInto(child); + } + } +} + +/** + * Push a theme refresh into `node`: directly if it is themed, or forwarded to + * themed descendants through any plain container in between. + * + * @internal + */ +export const cascadeThemeInto = (node: RenderNode): void => { + if (node instanceof ThemedContainer) { + node._refreshTheme(); + + return; + } + + if (node instanceof Container) { + for (const child of node.children) { + cascadeThemeInto(child); + } + } +}; diff --git a/src/ui/UIRoot.ts b/src/ui/UIRoot.ts index 489628ac9..1e31b81ec 100644 --- a/src/ui/UIRoot.ts +++ b/src/ui/UIRoot.ts @@ -1,7 +1,9 @@ import { Signal } from '#core/Signal'; -import { Container } from '#rendering/Container'; import type { RenderingContext } from '#rendering/RenderingContext'; +import { ThemedContainer } from './ThemedContainer'; +import type { UITheme } from './theme'; + /** * Root of a scene's screen-fixed UI layer. Reached through {@link Scene.ui}; * you do not construct it directly. @@ -15,14 +17,41 @@ import type { RenderingContext } from '#rendering/RenderingContext'; * * Add widgets with `scene.ui.addChild(...)`. The {@link UIRoot.onResize} signal * fires whenever the screen size changes, so anchored widgets can re-layout. + * + * The root also carries the layer's {@link UIRoot.theme}: widgets resolve their + * skins from the nearest themed ancestor, so assigning a theme here restyles + * every widget below that does not override it. */ -export class UIRoot extends Container { +export class UIRoot extends ThemedContainer { /** Fires with `(width, height)` whenever the screen size changes. */ public readonly onResize = new Signal<[width: number, height: number]>(); private _screenWidth = 0; private _screenHeight = 0; + /** + * Base theme for every widget in this layer. Widgets override parts of it + * per subtree with `Widget.setTheme`; there is no global theme, so two UI + * layers can carry different ones. Assigning restyles the whole layer at + * once - every widget below repaints and re-lays out. + */ + public override get theme(): UITheme { + return this._theme; + } + + public override set theme(value: UITheme) { + if (this._theme !== value) { + this._theme = value; + this._cascadeTheme(); + } + } + + /** @internal - a UI layer's theme is assigned, never inherited, so there is nothing to re-resolve here. */ + public override _refreshTheme(): void { + // A UIRoot owns its theme outright; the cascade stops descending only into + // its children, which it pushes itself when the theme is assigned. + } + /** Screen width the UI is laid out against, in logical pixels. */ public get screenWidth(): number { return this._screenWidth; diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts index f884d4527..98d0dd88a 100644 --- a/src/ui/Widget.ts +++ b/src/ui/Widget.ts @@ -1,6 +1,9 @@ import { Container } from '#rendering/Container'; import type { RenderNode } from '#rendering/RenderNode'; +import { ThemedContainer } from './ThemedContainer'; +import type { UISkin, UITheme, UIThemePatch, UIThemeRole, UIWidgetState } from './theme'; +import { createUITheme, resolveUISkin } from './theme'; import type { UIRoot } from './UIRoot'; /** Anchor position of a widget within its container's box. */ @@ -31,12 +34,21 @@ const anchorFactors = (anchor: WidgetAnchor): readonly [number, number] => { * (independent of child bounds / scale), an `enabled` flag, and optional * screen-edge anchoring that re-applies on resize. * - * Subclasses redraw size-dependent content in {@link Widget._relayout} and - * react to enable/disable in {@link Widget._onEnabledChanged}. + * Widgets read their look from the theme of the nearest themed ancestor - the + * layer's {@link UIRoot}, or any widget above them that set overrides with + * {@link Widget.setTheme}. + * + * Subclasses paint in {@link Widget._repaint}, place size-dependent content in + * {@link Widget._relayout}, and react to enable/disable in + * {@link Widget._onEnabledChanged}. */ -export abstract class Widget extends Container { +export abstract class Widget extends ThemedContainer { protected _uiWidth = 0; protected _uiHeight = 0; + /** The state this widget's skins are resolved for; see {@link Widget._setSkinState}. */ + protected _skinState: UIWidgetState = 'normal'; + private _themePatch: UIThemePatch | null = null; + private _inheritedTheme: UITheme = this._theme; private _enabled = true; private _effectiveEnabled = true; private _uiAnchor: WidgetAnchor | null = null; @@ -65,16 +77,30 @@ export abstract class Widget extends Container { if (this._uiWidth !== w || this._uiHeight !== h) { this._uiWidth = w; this._uiHeight = h; - this._relayout(); - - if (this._uiAnchorRoot !== null) { - this._applyAnchor(this._uiAnchorRoot.screenWidth, this._uiAnchorRoot.screenHeight); - } + this._invalidateLayout(); } return this; } + /** This widget's own theme overrides, or `null` when it purely inherits. */ + public get themeOverrides(): UIThemePatch | null { + return this._themePatch; + } + + /** + * Override parts of the inherited theme for this widget and its descendants. + * `null` clears the override. Repaints and re-lays out every widget in the + * subtree that the change reaches - skin insets are layout input, so a theme + * change is never paint-only. + */ + public setTheme(patch: UIThemePatch | null): this { + this._themePatch = patch; + this._refreshTheme(true); + + return this; + } + /** * The widget's own enabled flag, independent of any ancestor's. Disabling a * container widget does not change this on its children - see @@ -141,9 +167,63 @@ export abstract class Widget extends Container { this.setPosition(ax * (containerWidth - this._uiWidth) + this._uiAnchorOffsetX, ay * (containerHeight - this._uiHeight) + this._uiAnchorOffsetY); } - /** Redraw size-dependent content (background, child positions). Override in subclasses. */ + /** + * Redraw this widget's own painted surfaces for the current size and skin. + * Override in subclasses that draw a background. + */ + protected _repaint(): void { + // Overridden by subclasses that paint a sized surface. + } + + /** + * Re-place size-dependent content and repaint. Override in subclasses that + * position children; call `super._relayout()` to keep the repaint. + */ protected _relayout(): void { - // Overridden by subclasses that draw a sized background. + this._repaint(); + } + + /** + * Repaint without re-laying out - for a change that cannot move anything, + * such as a colour or a state flip. Applied immediately, not batched. + */ + protected _invalidatePaint(): void { + this._repaint(); + } + + /** + * Re-lay out and repaint, then re-apply screen anchoring. This is the right + * invalidation for anything a skin's insets or a font size can move, and for + * every size change. + */ + protected _invalidateLayout(): void { + this._relayout(); + + if (this._uiAnchorRoot !== null) { + this._applyAnchor(this._uiAnchorRoot.screenWidth, this._uiAnchorRoot.screenHeight); + } + } + + /** The skin `role` paints with in this widget's current state. */ + protected _skin(role: UIThemeRole): UISkin { + return resolveUISkin(this._theme[role], this._skinState); + } + + /** Switch the state skins resolve for, repainting when it actually changes. */ + protected _setSkinState(state: UIWidgetState): void { + if (this._skinState !== state) { + this._skinState = state; + this._invalidatePaint(); + } + } + + /** + * React to a resolved-theme change. The default re-lays out and repaints; + * override to re-read skin values that are cached elsewhere first, then call + * `super._onThemeChanged()`. + */ + protected _onThemeChanged(): void { + this._invalidateLayout(); } /** @@ -155,6 +235,31 @@ export abstract class Widget extends Container { // Overridden by interactive subclasses (e.g. Button dimming). } + /** + * @internal - re-resolve {@link ThemedContainer.theme} from the nearest + * themed ancestor and this widget's own overrides, then push the same + * recompute into every themed descendant. + */ + public override _refreshTheme(force = false): void { + const inherited = this._resolveInheritedTheme(); + + if (!force && inherited === this._inheritedTheme) { + return; + } + + this._inheritedTheme = inherited; + + const next = this._themePatch === null ? inherited : createUITheme(this._themePatch, inherited); + + if (next === this._theme && !force) { + return; + } + + this._theme = next; + this._onThemeChanged(); + this._cascadeTheme(); + } + /** * Recompute {@link effectiveEnabled} from the current own flag and parent * chain. No-ops if it did not actually change; otherwise fires @@ -215,6 +320,7 @@ export abstract class Widget extends Container { public override _setParent(parent: Container | null): void { super._setParent(parent); this._refreshEffectiveEnabled(); + this._refreshTheme(); } public override destroy(): void { diff --git a/test/ui/theme-cascade.test.ts b/test/ui/theme-cascade.test.ts new file mode 100644 index 000000000..65b63a400 --- /dev/null +++ b/test/ui/theme-cascade.test.ts @@ -0,0 +1,160 @@ +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import type { UIFillBackground, UITheme } from '#ui/theme'; +import { createUITheme, defaultUITheme } from '#ui/theme'; +import { UIRoot } from '#ui/UIRoot'; +import { Widget } from '#ui/Widget'; + +class ProbeWidget extends Widget { + public paints = 0; + public layouts = 0; + + protected override _repaint(): void { + this.paints++; + } + + protected override _relayout(): void { + this.layouts++; + super._relayout(); + } + + /** The panel fill this widget currently resolves, for state-free assertions. */ + public get panelColor(): Color { + return (this._skin('panel').background as UIFillBackground).color; + } +} + +const red: UITheme = createUITheme({ + panel: { normal: { background: { kind: 'fill', color: new Color(255, 0, 0, 1), borderColor: Color.black, borderWidth: 0, cornerRadius: 0 } } }, +}); +const blue: UITheme = createUITheme({ + panel: { normal: { background: { kind: 'fill', color: new Color(0, 0, 255, 1), borderColor: Color.black, borderWidth: 0, cornerRadius: 0 } } }, +}); + +describe('theme cascade', () => { + test('a detached widget reads the default theme', () => { + expect(new ProbeWidget().theme).toBe(defaultUITheme); + }); + + test('attaching to a UI layer adopts that layer theme', () => { + const root = new UIRoot(); + const widget = new ProbeWidget(); + + root.theme = red; + root.addChild(widget); + + expect(widget.theme).toBe(red); + }); + + test('assigning a layer theme restyles the widgets already in it', () => { + const root = new UIRoot(); + const widget = new ProbeWidget(); + + root.addChild(widget); + const layoutsBefore = widget.layouts; + + root.theme = red; + + expect(widget.theme).toBe(red); + expect(widget.layouts).toBe(layoutsBefore + 1); + }); + + test('reaches widgets through a plain container in between', () => { + const root = new UIRoot(); + const group = new Container(); + const widget = new ProbeWidget(); + + group.addChild(widget); + root.addChild(group); + root.theme = red; + + expect(widget.theme).toBe(red); + }); + + test('a widget inherits from the nearest widget ancestor, not the layer', () => { + const root = new UIRoot(); + const parent = new ProbeWidget(); + const child = new ProbeWidget(); + + root.theme = red; + root.addChild(parent); + parent.addChild(child); + parent.setTheme({ panel: { normal: { background: blue.panel.normal.background } } }); + + expect(child.panelColor.toRgba8()).toBe(new Color(0, 0, 255, 1).toRgba8()); + expect(parent.themeOverrides).not.toBeNull(); + }); + + test('overrides stay inside their subtree', () => { + const root = new UIRoot(); + const overridden = new ProbeWidget(); + const sibling = new ProbeWidget(); + + root.theme = red; + root.addChild(overridden); + root.addChild(sibling); + overridden.setTheme({ panel: { normal: { background: blue.panel.normal.background } } }); + + expect(sibling.theme).toBe(red); + }); + + test('clearing an override falls back to the inherited theme', () => { + const root = new UIRoot(); + const widget = new ProbeWidget(); + + root.theme = red; + root.addChild(widget); + widget.setTheme({ panel: { normal: { insets: { left: 4, top: 4, right: 4, bottom: 4 } } } }); + widget.setTheme(null); + + expect(widget.theme).toBe(red); + expect(widget.themeOverrides).toBeNull(); + }); + + test('reparenting re-resolves the inherited theme', () => { + const first = new UIRoot(); + const second = new UIRoot(); + const widget = new ProbeWidget(); + + first.theme = red; + second.theme = blue; + first.addChild(widget); + second.addChild(widget); + + expect(widget.theme).toBe(blue); + }); + + test('an unchanged theme does not repaint', () => { + const root = new UIRoot(); + const widget = new ProbeWidget(); + + root.theme = red; + root.addChild(widget); + const layoutsBefore = widget.layouts; + + root.theme = red; + widget._refreshTheme(); + + expect(widget.layouts).toBe(layoutsBefore); + }); +}); + +describe('paint and layout invalidation', () => { + test('a size change lays out and paints', () => { + const widget = new ProbeWidget(); + + widget.setSize(100, 50); + + expect(widget.layouts).toBe(1); + expect(widget.paints).toBe(1); + }); + + test('an unchanged size does neither', () => { + const widget = new ProbeWidget(); + + widget.setSize(100, 50); + widget.setSize(100, 50); + + expect(widget.layouts).toBe(1); + }); +}); From fe0ade01c751c4ee26c95e7ddaf7afef09f7e519 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 27 Aug 2026 21:32:35 +0200 Subject: [PATCH 3/6] feat(ui): paint widgets from skins and put their style behind setters Panel, Button, ProgressBar and Label read their look from the theme role they paint and keep only their own overrides. `WidgetBackground` owns the node a background descriptor needs - a `Graphics` for a fill, a `NineSliceSprite` for a texture - and swaps it in place at a fixed child slot, so the texture case is painted rather than merely declared. Style is no longer a mutable object handed out by a getter: `setFill`, `setBackground` and `setTextStyle` are the way in, and each one invalidates paint or layout as the change requires. Button carries one skin per state, so hover, pressed and disabled follow the theme instead of four hardcoded colours; label placement now respects the skin's insets. Serialization writes a widget's overrides instead of its resolved values, so a scene saved under one theme no longer replays that theme when it is loaded under another. --- src/core/serialization/serializerHelpers.ts | 30 +++ src/core/serialization/uiSerializers.ts | 54 ++++-- src/ui/Button.ts | 192 ++++++++++++++++---- src/ui/Label.ts | 50 ++++- src/ui/Panel.ts | 128 +++++++++---- src/ui/ProgressBar.ts | 132 +++++++++----- src/ui/UIRoot.ts | 2 +- src/ui/Widget.ts | 2 +- src/ui/WidgetBackground.ts | 111 +++++++++++ src/ui/index.ts | 20 +- src/ui/theme.ts | 47 ++++- test/core/serialization.test.ts | 2 +- test/ui/widgets.test.ts | 131 ++++++++++++- 13 files changed, 748 insertions(+), 153 deletions(-) create mode 100644 src/ui/WidgetBackground.ts diff --git a/src/core/serialization/serializerHelpers.ts b/src/core/serialization/serializerHelpers.ts index e90b63144..ff7e6db37 100644 --- a/src/core/serialization/serializerHelpers.ts +++ b/src/core/serialization/serializerHelpers.ts @@ -84,6 +84,36 @@ export const serializeStyle = (style: { }; /** Rebuild {@link TextStyleOptions} from serialized style data, or `undefined`. */ +/** + * Serialize a partial text style - a widget's own style overrides - writing + * exactly the fields it names, so a widget that takes its style from a theme + * round-trips as "no override" instead of baking that theme's values in. A + * `FontFace` has no serialized form and is skipped. + */ +export const serializeStyleOptions = (options: TextStyleOptions | null): Record | undefined => { + if (options === null) { + return undefined; + } + + const out: Record = {}; + + for (const [key, value] of Object.entries(options)) { + if (value === undefined || key === 'font') { + continue; + } + + if (value instanceof Color) { + out[key] = colorToArray(value); + } else if (Array.isArray(value)) { + out[key] = (value as unknown[]).map(entry => (entry instanceof Color ? colorToArray(entry) : entry)); + } else { + out[key] = value; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +}; + export const deserializeStyleOptions = (data: unknown): TextStyleOptions | undefined => { if (typeof data !== 'object' || data === null) { return undefined; diff --git a/src/core/serialization/uiSerializers.ts b/src/core/serialization/uiSerializers.ts index f2c1ce56e..982fc72b3 100644 --- a/src/core/serialization/uiSerializers.ts +++ b/src/core/serialization/uiSerializers.ts @@ -5,15 +5,28 @@ import { Panel } from '#ui/Panel'; import { ProgressBar } from '#ui/ProgressBar'; import { ScrollContainer, type ScrollDirection } from '#ui/ScrollContainer'; import { Stack } from '#ui/Stack'; +import type { UIFillPatch } from '#ui/theme'; import { UIRoot } from '#ui/UIRoot'; import type { NodeSerializer } from './NodeSerializer'; import { asSerializedNode } from './read'; import type { SerializationRegistry } from './SerializationRegistry'; -import { arrayToColor, colorToArray, compact, deserializeStyleOptions, serializeStyle } from './serializerHelpers'; +import { arrayToColor, colorToArray, compact, deserializeStyleOptions, serializeStyleOptions } from './serializerHelpers'; const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); +/** The fields a widget's fill overrides contribute, omitting what it does not override. */ +const serializeFill = (fill: UIFillPatch | null): Record => ({ + ...(fill?.color !== undefined && { color: colorToArray(fill.color) }), + ...(fill?.borderColor !== undefined && { borderColor: colorToArray(fill.borderColor) }), + ...(fill?.borderWidth !== undefined && { borderWidth: fill.borderWidth }), + ...(fill?.cornerRadius !== undefined && { cornerRadius: fill.cornerRadius }), +}); + +/** Just the colour of a fill override, under the key the widget's options use for it. */ +const serializeFillColor = (key: string, fill: UIFillPatch | null): Record => + fill?.color !== undefined ? { [key]: colorToArray(fill.color) } : {}; + // Widget composition note: widgets own internal children (a Label's Text, a // Panel's background Graphics, a ScrollContainer's content Container, etc.) that // their constructors rebuild - those are never serialized. Only user-added @@ -21,13 +34,18 @@ const num = (value: unknown): number | undefined => (typeof value === 'number' & // round-trip; for ScrollContainer those live one level down, inside `content`. // Anchoring (anchorIn) references a UIRoot and is not serialized; the resolved // position still round-trips via the common fields. +// +// Style: only a widget's OWN overrides round-trip, never the values it resolved +// from a theme - otherwise loading a scene under a different theme would replay +// the theme it was saved under. Whole-background overrides (`setBackground`) +// are skipped as well: a texture reference has no serialized form here. // ── Label ──────────────────────────────────────────────────────────────────── const labelSerializer: NodeSerializer