# `Mob.UI`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/ui.ex#L1)

UI component constructors for the Mob framework.

Each function returns a node map compatible with `Mob.Renderer`. These can
be used directly, via the `~MOB` sigil, or mixed freely — they produce the
same map format.

    # Native map literal
    %{type: :text, props: %{text: "Hello"}, children: []}

    # Component function (keyword list or map)
    Mob.UI.text(text: "Hello")

    # Sigil (import Mob.Sigil or use Mob.Screen)
    ~MOB(<Text text="Hello" />)

All three forms produce identical output and are accepted by `Mob.Renderer`.

# `camera_preview`

```elixir
@spec camera_preview(keyword() | map()) :: map()
```

Returns a `:camera_preview` component node. Renders a live camera feed inline.

Call `MobCamera.start_preview/2` (the `mob_camera` plugin) before mounting this
component, and `MobCamera.stop_preview/1` when done.

Props:
  * `:facing` — `:back` (default) or `:front`
  * `:width`, `:height` — dimensions in dp/pts; omit to fill parent

# `canvas`

```elixir
@spec canvas(keyword() | map()) :: map()
```

Returns a `:canvas` leaf node — declarative 2D drawing surface backed
by SwiftUI `Canvas` on iOS and Jetpack Compose `Canvas` on Android.

Coordinates are canvas-local in points/dp, top-left origin.

## Props

  * `:width` — canvas width in pt/dp (required)
  * `:height` — canvas height in pt/dp (required)
  * `:draw` — list of op maps (required); construct via `Mob.Canvas.line/5`,
    `Mob.Canvas.circle/4`, etc., or as raw maps with an `:op` key
  * `:on_tap` — `{pid, tag}`; a tap anywhere on the canvas delivers
    `{:tap, tag}` to `handle_info/2`
  * `:on_drag` — `{pid, tag}` or `{pid, tag, throttle_opts}`; finger drags
    deliver `{:drag, tag, payload}` (see the events guide for the payload)

Color tokens inside draw ops are resolved against the active theme
by `Mob.Renderer` before serialisation, exactly like top-level color
props on text/button/etc.

## Example

    import Mob.UI
    import Mob.Canvas

    canvas(width: 240, height: 240, draw: [
      circle(120, 120, 115, color: :surface_outline, width: 2),
      line(60, 60, 60, 180, color: :primary, width: 8, cap: :round),
      line(60, 180, 180, 180, color: :primary, width: 8, cap: :round),
      line(60, 60, 180, 180, color: :primary, width: 8, cap: :round)
    ])

See `Mob.Canvas` for the full op list and modifier reference.

# `gpu_view`

```elixir
@spec gpu_view(keyword() | map()) :: map()
```

Returns a `:gpu_view` leaf node — a fragment-shader-driven GPU surface
backed by `MTKView` + Metal on iOS. The native side compiles the
supplied shader (Metal Shading Language) into a render pipeline, binds
the supplied uniforms in declaration order at fragment buffer slot 0,
and renders a full-screen quad at the display refresh rate.

Android support (`GLSurfaceView` + GLES 3.0) is not in v1.

## Props

  * `:id` — required atom that identifies the GPU view across re-renders
    (so the native side keeps the same Metal pipeline / texture cache).
  * `:width` / `:height` — pt/dp, required.
  * `:shader` — either a string of Metal Shading Language source (iOS),
    or a map `%{ios: "...MSL..."}` (escape hatch — same as the string
    form; the map form exists so future platforms can be added without
    breaking the API).
  * `:uniforms` — an **ordered list of values** packed into the shader's
    `Uniforms` struct in declaration order. Each element is one of:
    * a number — `float` (or `uint` if integer-typed at the BEAM level)
    * a 2-element list `[a, b]` — `float2`
    * a 4-element list `[a, b, c, d]` — `float4`
    (`float3` deliberately not supported in v1 — its 16-byte
    alignment with 12-byte size makes the layout API messier than
    it's worth here.)

Shader compile errors are caught natively and surfaced as a translucent
overlay on top of the GpuView with the error message.

## Why a list, not a map

Elixir map iteration order is **not stable** across runtimes or map
sizes — `%{a: 1, b: 2, c: 3}` can iterate in any order. The natural
MSL layout for a `Uniforms` struct is positional, so we mirror that
on the BEAM side. List position 0 → first struct member, etc.

A map form is still accepted as a backward-compat fallback but will
pack in whatever order the runtime decides, so the shader-side struct
has to match an unstable order — not recommended.

## Example — Mandelbrot at the display's refresh rate

    @shader File.read!("priv/shaders/mandelbrot.metal")

    Mob.UI.gpu_view(
      id: :mandelbrot,
      width: 350,
      height: 350,
      shader: @shader,
      # MSL: struct Uniforms { float2 center; float zoom; uint max_iter; };
      uniforms: [[cx, cy], zoom, max_iter]
    )

## What the framework auto-provides

The host emits a built-in vertex shader that draws a full-screen quad
and produces a `VertexOut { float4 position [[position]]; float2 uv; }`.
Your fragment shader receives that as `[[stage_in]]` and reads
`in.uv` (0..1 across the view) plus the user uniforms at buffer slot 0.
Don't redeclare `VertexOut`, `vertex_main`, or the metal_stdlib include
in your shader — the host prepends them.

## Required fragment entry point

Your shader must export `fragment_main`:

    fragment half4 fragment_main(VertexOut in [[stage_in]],
                                 constant Uniforms& u [[buffer(0)]]) { ... }

# `native_view`

```elixir
@spec native_view(module(), keyword() | map()) :: map()
```

Returns a `:native_view` node that renders a platform-native component.

`module` must implement the `Mob.Component` behaviour and be registered
on the native side via `MobNativeViewRegistry`. The `:id` must be unique
per screen — a duplicate raises at render time.

All other props are passed to `mount/2` and `update/2` on the component.

## Example

    Mob.UI.native_view(MyApp.ChartComponent, id: :revenue_chart, data: @points)

# `sheet`

```elixir
@spec sheet(map() | [map()], keyword() | map()) :: map()
```

Returns a `:sheet` node — a native modal bottom sheet (iOS `.sheet`,
Android Material 3 `ModalBottomSheet`) that composes ordinary Mob nodes
as its content.

`children` is one child node or a list of them.

## Props

  * `:detents` — nonempty, duplicate-free subset of `[:medium, :large]`,
    or the exclusive content-height detent `[:content]` /
    `[{:content, max_height: number}]`. Defaults to `[:medium, :large]`.
    `:medium` alone rejects expansion to full height; `:large` alone skips
    the half-height stop. A content detent wraps intrinsic content and caps
    overflow in an internally scrolling body.

    Two things to know about content detents. They size from the content's
    *intrinsic* height, so a scrollable child (`scroll`, `lazy_list`) reports
    its full content height rather than a viewport height — it will expand
    inside the sheet and the sheet's own scroll takes the gesture, instead of
    the child scrolling independently. Use `:medium`/`:large` when the sheet's
    body is itself scrollable. And the sheet only knows its content height
    once it is on screen, so it presents at `:medium` for the first frame and
    resizes to the measured height immediately after.

    Invalid `:detents` raise. `Mob.Renderer` re-validates at the encode
    boundary through `normalize_sheet_detents!/1`, so a hand-built or `~MOB`
    sigil node cannot bypass this by skipping `sheet/2` — such a node now
    raises during render rather than silently degrading to
    `[:medium, :large]` on the native side.
  * `:on_dismiss` — `{pid, tag}`, delivered as `handle_info({:dismiss, tag}, socket)`
    exactly once when the sheet is dismissed (swipe-down, back gesture,
    or outside tap) — the same `{atom, tag}` wire shape as `on_focus`,
    `on_blur`, `on_submit`, and `on_select` elsewhere in `Mob.UI`.
  * `:background` — container color: a theme token atom or a
    `0x00000000..0xFFFFFFFF` ARGB integer.
  * `:scrim` — dimming-layer color, same value shape as `:background`.
    **iOS cannot honor this exactly** — see the note below.
  * `:corner_radius` — top-corner radius: a theme radius token atom or
    a non-negative number.
  * `:drag_indicator_color`, `:drag_indicator_width`,
    `:drag_indicator_height`, `:drag_indicator_rail_height` — a custom
    drag-indicator capsule. All four are required together, or omit
    all four for the platform default indicator. Width and height must
    be positive; rail height must be at least the indicator height (the
    rail is the invisible touch target the visible capsule sits inside).
  * `:ios` / `:android` — per-platform overrides. Each accepts only the
    style keys above (not `:detents` or `:on_dismiss`); see `Mob.Renderer`'s
    "Platform blocks" section for the general override mechanism.

## Example

    Mob.UI.sheet(
      Mob.UI.text(text: "Hello from the sheet"),
      detents: [:medium, :large],
      on_dismiss: {self(), :dismiss_sheet},
      background: :surface,
      scrim: 0x33000000,
      corner_radius: 10,
      drag_indicator_color: :muted,
      drag_indicator_width: 36,
      drag_indicator_height: 5,
      drag_indicator_rail_height: 22,
      ios: %{corner_radius: 10},
      android: %{corner_radius: 28}
    )

## Platform limitation: scrim opacity on iOS

Android applies `:scrim` exactly — the sheet's dimming layer is drawn
with the requested color, alpha included. iOS's `.sheet` presentation
owns its dimming layer and does not expose an API to configure its
opacity; supported SwiftUI APIs leave it system-black at a fixed,
non-configurable alpha. There is no workaround that doesn't involve
private view-hierarchy manipulation, which this framework does not do.
If your design depends on exact scrim opacity, treat it as
Android-only and expect iOS to look slightly different.

# `text`

```elixir
@spec text(keyword() | map()) :: map()
```

Returns a `:text` leaf node.

## Props

  * `:text` — the string to display (required)
  * `:text_color` — color value passed to `set_text_color/2` in the NIF
  * `:text_size` — font size in sp passed to `set_text_size/2` in the NIF
  * `:max_lines` — integer from 1 through 2,147,483,647 that caps rendered
    lines; overflow is truncated with an ellipsis. Omit to wrap without limit.

## Examples

    Mob.UI.text(text: "Hello")
    #=> %{type: :text, props: %{text: "Hello"}, children: []}

    Mob.UI.text(text: "Hello", text_color: "#ffffff", text_size: 18)
    #=> %{type: :text, props: %{text: "Hello", text_color: "#ffffff", text_size: 18}, children: []}

# `webview`

```elixir
@spec webview(keyword() | map()) :: map()
```

Returns a `:webview` component node. Renders a native web view inline.

The JS bridge is injected automatically — the page can call `window.mob.send(data)`
to deliver messages to `handle_info({:webview, :message, data}, socket)`, and
Elixir can push to JS via `Mob.WebView.post_message/2`.

Props:
  * `:url` — URL to load (required)
  * `:allow` — list of URL prefixes that navigation is permitted to (default: allow all).
    Blocked attempts arrive as `{:webview, :blocked, url}` in `handle_info`.
  * `:show_url` — show a native URL label above the WebView (default: false)
  * `:title` — static title label above the WebView; overrides `:show_url`
  * `:width`, `:height` — dimensions in dp/pts; omit to fill parent

---

*Consult [api-reference.md](api-reference.md) for complete listing*
