Skip to content

Theming

This page describes how FlashBang’s theming engine works—the data model, resolution step, and draw pipeline—as implemented in src/style/theme.odin, src/style/resolve.odin, and src/widgets/common.odin. Any concrete theme (flat, skeuomorphic, game HUD, etc.) is just values + optional procedure hooks on Theme; the core does not assume a particular skin or third-party theme package.

Design paradigm

  1. Theme is data — A Theme value holds colors, numeric parameters, and optional callback slots. Widgets do not mutate the theme; they read it (and copies on the stack are fine for push_theme).
  2. State drives resolution — Widget code decides WidgetVisualState (hover, pressed, etc.) from input/focus/disabled rules, then asks the engine for paint parameters.
  3. Resolution → surfaceresolve_surface (or a custom theme.resolve) produces a ResolvedSurface: a renderer-neutral bundle of colors, border, corner radius, focus-ring flags, and optional depth metadata. That struct is the contract between “what the theme meant” and “what emit_surface will draw.”
  4. Drawing is centralizedemit_surface implements the default composition (optional pre-hook, body fill, border, focus ring). Widgets share one path so behavior stays consistent.
  5. Overrides are explicit — Per-widget Maybe(style.Color) fields patch the resolved surface via apply_color_override after resolution, without editing the global theme.
  6. No selector language — There is no CSS-like matching. Rules are procedural: your resolve proc (or the built-in fallback) implements whatever policy you want.

Theme: capability surface

The struct is the full list of what the engine can consult. Rough groups:

  • Optional render hooks (nil = use built-in behavior for that concern):
  • resolve — Replace default resolve_surface logic entirely.
  • geometry — Emit extra draw ops before the standard body (e.g. layered rects for depth cues).
  • check_indicator, window_chrome, separator_geometry, group_box_label — Widget-specific extension points used only by the widgets that call them.
  • Scalar / color fields — Used by the default resolver and by custom resolve implementations however you define (e.g. surface_color, corner_radius, focus ring fields, window chrome sizing, spacing).

The authoritative field list and comments are in src/style/theme.odin.

WidgetVisualState

Enumeration in src/style/resolve.odin: NORMAL, HOVERED, PRESSED, FOCUSED, DISABLED. Widgets classify interaction state; the theme maps that enum (plus context) to a ResolvedSurface.

The extruded parameter

resolve_surface(theme, state, extruded) takes a boolean hint from the widget author: “this control is logically raised vs inset for styling purposes.” The built-in flat fallback largely ignores depth; a custom theme.resolve may use extruded to branch (e.g. invert depth sign, different border treatment). It is not a guarantee of 3D visuals—that depends entirely on whether your theme supplies depth-related fields and hooks.

Built-in resolution (no custom resolve)

If theme.resolve == nil, resolve_surface applies a minimal flat mapping: theme colors, simple border, disabled text color. Highlight/shadow/depth fields on ResolvedSurface are set to neutral values suitable for flat UI. This is a reference implementation, not a prescription for how custom themes should look.

Per-widget color overrides

After resolution, widgets may call apply_color_override so optional Maybe(style.Color) fields on configs replace fill/border/text on the ResolvedSurface (and adjust derived edge colors when fill changes). This is engine-level patching, not theme editing.

emit_surface pipeline

Regardless of theme “style,” the default emitter:

  1. Runs theme.geometry first if set (extension point).
  2. Draws the body from resolved.surface_color.
  3. Draws border when border_width is positive.
  4. Draws focus ring if enabled on the resolved surface.

So the pipeline is fixed; the numbers and optional pre-layer come from resolution + theme hooks.

Active theme in immediate mode

set_immediate_context ensures a default theme exists on first use and resets the per-frame theme stack. Application code uses set_theme, push_theme, pop_theme, and widgets read active_theme(ctx). Scoped pushes are per frame in the current implementation—reapply each frame if you rely on local overrides.

Built-in theme constructors

default_theme() and basic_theme() return a small flat theme with no custom hooks (see src/style/theme.odin). They exist so the engine runs out of the box; your game or tool replaces or wraps them with a Theme built however you like.

Separators

emit_separator consults theme.separator_geometry when non-nil; otherwise the engine draws a simple line using resolved border color. Same pattern: hook optional, fallback generic.

Example in practice

Membrane is the bundled theme that exercises resolve, geometry, indicators, separators, group-box label, and window chrome. Reading it alongside this page shows how a full visual system maps onto the engine’s hooks without changing widget code.