Skip to content

Example theme: Membrane

Membrane is a concrete style.Theme shipped with FlashBang under themes/membrane/ in the library repository. It exists to show how the theming engine’s hooks work end-to-end: custom resolve, geometry, and widget-specific procs, not just palette swaps.

This page describes Membrane as an instance of the general model on Styling & themes.

What Membrane looks like (design intent)

Source comments in themes/membrane/theme.odin describe it as a dark, organic neumorphic skin:

  • base_color and surface_color are the same (MAIN_COLOR), so widgets sit in the same material as the background; separation comes from shading (depth layers), not a hard outline.
  • border_width is driven to zero in membrane_resolve—edges read as soft gradients from membrane_geometry, not stroked borders.
  • Custom window chrome (window_decoration = .CUSTOM) so title bar and buttons match the same language.

Using Membrane in an app

You build a Theme value once (or when switching skins) and hand it to FlashBang. The Membrane sources live under themes/membrane/ in the FlashBang tree (package name membrane). Point an import at that directory using whatever layout you use—relative path, a collection: root, a git submodule, etc.:

import membrane "../path/to/flashbang/themes/membrane"  // adjust to your layout

flashbang.set_theme(membrane.create())

Scoped overrides still use push_theme / pop_theme like any other theme. Membrane does not change the immediate-mode stack rules—only the Theme contents.

How the engine maps to Membrane

FlashBang’s contract is: widgets call style.resolve_surface (which delegates to theme.resolve when set), optionally apply_color_override, then emit_surface. emit_surface always runs theme.geometry first (if non-nil), then the standard body / border / focus ring.

Membrane wires every optional hook on Theme in membrane.create():

Theme field Membrane proc Role
resolve membrane_resolve State + extrudedResolvedSurface with depth, soft edges, highlight/shadow colors, no hard border.
geometry membrane_geometry Layered semi-transparent rects before the body; uses ResolvedSurface.depth, edge_softness, corner_radius.
check_indicator membrane_check_indicator Checkbox checkmark from overlapping circular blocks, each passed through membrane_geometry.
separator_geometry membrane_separator_geometry Groove: paired shadow/highlight 1px lines.
group_box_label membrane_group_box_label Pill label: resolve + geometry + body fill.
window_chrome membrane_window_chrome Min/max/close (and related) as membrane blocks—see themes/membrane/window_chrome.odin.

So: resolution picks numbers and colors; geometry (and the specialized procs) turn those into extra draw ops the flat engine never emits by itself.

membrane_resolve (custom ResolveSurfaceProc)

Implemented in themes/membrane/resolve.odin.

  1. Depth sign — Starts from extrusion_depth if extruded, else negative indentation_depth, so raised vs inset controls get opposite shading semantics without separate widget code paths in the resolver.
  2. WidgetVisualState — Adjusts depth, edge softness, and surface tint (e.g. hover sharpens edges and lightens slightly; press uses press_depth_scale to invert depth; disabled flattens and desaturates).
  3. Tint from depth — Small lighten/darken from signed depth so extruded vs recessed read clearly.
  4. Highlight / shadow colorsstyle.compute_edge_colors combines surface, signed depth, light_angle, and strength fields from Theme.
  5. ResolvedSurface — Sets border_width = 0 on purpose: Membrane relies on membrane_geometry for edge read, not emit_surface’s stroke pass.

This is the clearest example of extruded as a hint: the same resolver runs for many widgets; extruded chooses which depth convention applies.

membrane_geometry (custom SurfaceGeometryProc)

Implemented in themes/membrane/geometry.odin.

  • Casts draw_list_raw to ^rendering.DrawList and emits MEMBRANE_LAYERS (10) stacked shadow rects on one diagonal and highlight rects on the other, with increasing offset, slight grow, and falling alpha—soft neumorphic rim.
  • resolved.depth nonnegative vs negative flips which side is “lit,” so recessed widgets invert the direction.
  • push_z_layer(list, list.current_z - 1) so these layers sit under the main body neighbors paint over—documented as important for clean overlap (see also comments in split_pane.odin in the library).
  • spread is clamped by widget size so tiny indicators do not get huge halos.

emit_surface calls this before the flat body fill, so the body sits on top of the gradient stack.

Other Membrane-specific pieces (short)

  • Checkmark — Seven circular blocks along a polyline path; pass 1 all membrane_geometry, pass 2 all bodies so interiors stay clean.
  • Separators — Two thin fills (shadow + highlight) for a crease; no shared “separator widget” magic beyond SeparatorGeometryProc.
  • Group box label — Resolves NORMAL + extruded, rounds into a pill radius, runs membrane_geometry, then pill RectFill.
  • Window chrome — Composes membrane_geometry and small rects/circles for controls; full detail in window_chrome.odin.

Contrasting with default_theme

style.default_theme() leaves resolve, geometry, and the other hooks nil. The engine uses the built-in flat resolve_surface path and emit_surface without pre-layers. Membrane is the same pipeline—only the Theme value fills in behavior through hooks.

Source layout (FlashBang repo)

File Contents
themes/membrane/theme.odin create() — palette, lighting, spacing, hook assignments.
themes/membrane/resolve.odin membrane_resolve.
themes/membrane/geometry.odin membrane_geometry, indicator, separator, group box label.
themes/membrane/window_chrome.odin membrane_window_chrome.

Membrane’s own .odin files import FlashBang subpackages (e.g. style, rendering) using paths wired for this repository’s build. Your project must resolve those imports consistently (same FlashBang checkout, matching odin root or collection paths). The important API surface for apps remains membrane.create() -> style.Theme and flashbang.set_theme. When you integrate FlashBang (submodule, fork layout, or a future packaged layout), align Membrane’s imports with that setup—the theming engine does not depend on any one import prefix.