App

Application callbacks, startup configuration, and per-cycle input.

An app provides three callbacks:

program = { init!, update!, render! }

init! runs once after host facilities are ready and returns the first model. Use App.init to pair a Config with the callback.

update! runs once per host cycle. It receives the model and one App.Input, calls host effects directly, starts tasks, and returns the next model -- or Err(Exit(code)) to stop the app.

render! receives that model and a Draw.Frame, and draws. It cannot change the model or do host work of any other kind.

Each effect documents its legal phases. Host-state effects are legal in init!, update!, and tasks. Drawing effects are legal only in render!. Waiting effects are legal in init!, where they block startup, and in tasks, where they park the task. Capture.Writer.screenshot! is task-only because it waits for a frame to finish.

Calling an effect from a phase it does not permit is a programmer error, not a runtime outcome: it stops the app at once with a message naming the effect, the phase it was called from, and where it belongs.

Work that waits belongs on a task. Task.spawn!(input, || ...), from update! or from another task, gives the host an effectful closure to run on its own stack. When the closure returns, its value is delivered as a message on input.messages in a later cycle, in the order the tasks finished. A task cannot read or write the model, so its message is the only thing it can say. See Task.

The app ends when update! returns Err(Exit(code)), when the exit key is pressed, or when the user closes the window. By default the host closes the window itself. An app with work to finish first -- a save, say -- chooses App.Config.with_close_request(Deliver): the request then arrives as input.window.close_requested, the window stays open, and the app exits when it is ready, typically once the message of the task it started arrives.

For pure tests, build input with App.Input.for_tests({}) and its with_* methods. Host resource types provide inert stub values for constructing models; stubs cannot test loading or resource lifetime.

default_test_size : { width : I32, height : I32 }

The window size Input.for_tests reports. Ordinary rather than special: a test that depends on the size should say so with with_window.

default : Config

Default 800x600 window configuration capped at 240 FPS.

init : Config, InitCallback(model, errors) -> Init(model, errors)

Build initialization from a static startup configuration.

init_for_args : ConfigForArgs, InitCallback(model, errors) -> Init(model, errors)

Build initialization from an argv-aware startup configuration.

Dropped : { path : Str, position : { x : F32, y : F32 } }

One file dropped onto the window, and where the pointer was when it landed.

The path is the one the window system reported, absolute on every platform. It is an observation, not access: the user dropping the file is what grants it, so turn the path into a handle with io.files().accept_drop! in the update! that received it, then read through the handle on a task:

match io.files().accept_drop!(drop.path) {
    Ok(item) => Task.spawn!(input, || Opened(item.read_bytes!()))
    Err(PermissionDenied) => {}
}

A path kept past that update! is only a string.

position is the pointer position the host sampled for the cycle the drop arrived on, in the same logical coordinates as input.devices.mouse.position(), so an app that has more than one drop target can tell which one the file landed on.

Input

:= {
    devices : Snapshot,
    window : Snapshot,
    time : Cycle,
    messages : List(msg),
    capture : Status,
    dropped : List(Dropped),
    dropped_overflow : Bool,
}

Everything the host observed for one cycle, handed to update!.

messages contains every task message delivered for this cycle, in the order the tasks finished. Independent tasks may finish in any order; the order they were spawned in does not constrain it. capture contains the recording status sampled for this cycle.

dropped contains the files dropped onto the window since the previous input, in the order the window system reported them. Like a key press it is an interval event rather than a latest value: it is empty on almost every cycle, and exactly one call to update! sees any given drop. At most 64 paths are delivered per cycle; a single drop with more has its extra paths discarded, and dropped_overflow says so.

fields : Input(msg) -> {
    devices : Snapshot,
    window : Snapshot,
    time : Cycle,
    messages : List(msg),
    capture : Status,
    dropped : List(Dropped),
    dropped_overflow : Bool,
}

Return the complete structural input for platform-independent libraries.

from_fields : {
    devices : Snapshot,
    window : Snapshot,
    time : Cycle,
    messages : List(msg),
    capture : Status,
    dropped : List(Dropped),
    dropped_overflow : Bool,
} -> Input(msg)

Build an input by stating every sampled field at once.

This is the from-scratch constructor; for_tests is the one to use when only a field or two matters, since it supplies neutral values for the rest.

Pass a structural record written out here. Use fields when reading an existing input and the with_* methods when changing one field.

for_tests : {} -> Input(msg)

A neutral input for testing an app's pure update logic from an expect.

Nothing is pressed, the window is an ordinary focused default_test_size nobody has asked to close, the clock reads zero on its first cycle, no messages arrived, and nothing is recording. Customize it with the with_* methods, which is what makes a test say only the one thing it is about:

expect
    input = App.Input.for_tests({}).with_devices(Devices.none.with_key_pressed(KeySpace))
    decide(model, input) == Jump

Building the model this is called with is the other half: every host resource an app can hold has a resource-free stub (Text.font_stub, Audio.Sound.stub, Text.Prepared.stub, Assets.Texture.stub, ...), so a Model full of assets can be written down in a pure test.

update! itself is effectful, and an expect cannot call it. Keep the decisions in pure functions (how each message changes the model, whether to quit, which tasks to start) and test those; update! is the thin shell that carries them out.

with_devices : Input(msg), Snapshot -> Input(msg)

Replace this input's sampled device snapshot. Build one from Devices.none.

with_window : Input(msg), Snapshot -> Input(msg)

Replace this input's sampled window geometry and visibility, and whether the user asked the window to close:

closing = input.with_window({ ..input.window, close_requested: Bool.True })
with_time : Input(msg), Cycle -> Input(msg)

Replace this input's clock sample. Use it to drive a second cycle: input.with_time({ ..Time.first_cycle, cycle_count: 1 }).

with_messages : Input(msg), List(msg) -> Input(msg)

Deliver task messages on this input, in the order the tasks finished.

with_message : Input(msg), msg -> Input(msg)

Deliver one more task message on this input, after any already there.

with_capture : Input(msg), Status -> Input(msg)

Replace this input's sampled recording status.

with_dropped : Input(msg), List(Dropped) -> Input(msg)

Deliver files dropped onto the window on this input.

with_dropped_overflow : Input(msg), Bool -> Input(msg)

Say that this cycle's drop carried more paths than the host delivers, which is what input.dropped_overflow reports.

FramePacing : AppFramePacing

Mutually exclusive frame pacing strategy: VSync, Capped(fps), or Uncapped. Config normalization maps a non-positive Capped value to Uncapped before a Config can be created.

The signature names the module-private nominal this is an alias of; App.FramePacing is the name to write.

ExitKey : ExitKey

Which key, if any, ends the app: ExitKey(key) or NoExitKey, which disables the behaviour. The exit key ends the app directly, before update! sees the press, whatever CloseRequest says, and a scripted press (--host-keys, Keys.set_source!) does the same as a real one.

This is Keys.ExitKey, re-exported. The signature renders as ExitKey : ExitKey because the alias and the nominal share a name; they are one type, and a value passes between the two spellings freely.

CloseRequest : [Exit, Deliver]

What happens when the user asks the window to close, with its close button or the system's quit command.

  • Exit, the default: the host closes the window and the app ends before the next update!. Live tasks are cancelled and their messages are never delivered.
  • Deliver: the host keeps the window open and reports the request as input.window.close_requested on the next cycle. The app ends when update! returns Err(Exit(code)), so it can finish a save first.

The exit key is separate from both; see ExitKey.

Config

App.Config :: # (opaque)

Validated startup configuration. Its fields cannot be updated directly; use its with_* methods so startup invariants are preserved.

with_title : Config, Str -> Config

Return a config with a different window title.

with_size : Config, { width : I32, height : I32 } -> Config

Return a config with different initial logical dimensions. Each non-positive dimension independently falls back to the 800x600 default.

with_min_size : Config, { width : I32, height : I32 } -> Config

Return a config with a minimum window size the user cannot shrink past. Each negative dimension is clamped to 0, which means "no limit" in that axis. A minimum only takes effect on a resizable window, so pair this with with_resizable(Bool.True).

with_frame_pacing : Config, FramePacing -> Config

Return a config with a validated frame-pacing strategy.

with_exit_key : Config, ExitKey -> Config

Return a config with a different exit key. NoExitKey stops any key from ending the app; the window's close button still works, as with_close_request says.

with_close_request : Config, CloseRequest -> Config

Choose what the window's close button does: Exit (the default) ends the app, and Deliver reports the request to update! and leaves ending the app to it.

config = App.default.with_close_request(Deliver)

update! = |model, input, io| {
    if input.window.close_requested and !model.saving {
        saves = model.saves
        Task.spawn!(input, || Saved(saves.write_text!("save.json", encode(model))))
        Ok({ ..model, saving: Bool.True })
    } else if model.saving and List.len(input.messages) > 0 {
        Err(Exit(0))
    } else {
        Ok(model)
    }
}

Under Deliver an app that never exits cannot be closed with the button, so return Err(Exit(code)) once the request is handled. --host-close=CYCLE makes a request on a given cycle in a headless or hidden run, so a test can script it.

The exit key is not a close request. Under either choice it ends the app directly, before update! sees the press, so with the default ExitKey(KeyEscape) Escape still quits a Deliver app without a save. An app that must finish work on every way out also sets with_exit_key(NoExitKey) and handles the key it wants in update!.

with_cursor_mode : Config, CursorMode -> Config

Return a config with a different initial cursor mode.

with_resizable : Config, Bool -> Config

Return a config that enables or disables native window resizing.

with_fullscreen : Config, Bool -> Config

Return a config that starts in or out of fullscreen mode.

with_visible : Config, Bool -> Config

Return a config whose window is shown or hidden at startup.

A hidden window still renders on the GPU, so Capture works exactly as it does with a visible one -- useful for rendering a chart or a short clip to a file without a window appearing. This is not the same as the host's --host-headless flag, which draws nothing at all, and it still needs a display server (wrap it in xvfb-run on a machine without one).

with_output_dir : Config, Str -> Config

Return a config whose captures are written under a different directory, created on first use.

Captures are the app's own output, so writing them needs no permission; in exchange the directory is confined. It is relative to the working directory and may not be absolute or contain .., and the host refuses to start with one that does. Every Capture path resolves beneath it, and one that would escape it is refused rather than rewritten. An empty value means the working directory.

with_recording : Config, Recording -> Config

Store a recording description in the configuration. This is pure data; start it explicitly with io.capture().start!(recording) in init!.

without_recording : Config -> Config

Return a config with startup recording disabled.

with_default_font : Config, { path : Str, size : I32 } -> Config

Load the startup default font from a working-directory-relative asset path at the requested base pixel size. The path is validated and loaded once before Io.default_font! returns it.

Reading it needs a declared directory covering the path, such as Directory("assets", ReadOnly).

with_app_id : Config, Str -> Config

Name the app for its private storage: the data, config, and cache directories Files keeps for it under the user's home.

Use a reverse-DNS style name the app will keep, such as "dev.example.pong" -- 1 to 128 ASCII letters, digits, ., -, or _. It becomes a directory name, so if you change it later, the app can no longer find its saved data. An app that never touches private storage does not need one.

with_permission : Config, Permission -> Config

Declare access beyond the app's own resources: a network origin, a directory, a program, an environment variable, the clipboard. The declaration is the grant; see Permission.

Call it once per declaration. Declarations accumulate, and a duplicate is harmless.

with_permissions : Config, List(Permission) -> Config

Declare several permissions at once, in order, as repeated with_permission calls would:

App.default.with_permissions([HttpOrigin("https://api.example.com"), EnvVar("API_TOKEN")])
title : Config -> Str

Inspect the window title.

size : Config -> { width : I32, height : I32 }

Inspect the initial logical window dimensions.

min_size : Config -> { width : I32, height : I32 }

Inspect the minimum window size. A 0 in either axis means the window is unconstrained in that direction.

frame_pacing : Config -> FramePacing

Inspect the selected frame-pacing strategy.

cursor_mode : Config -> CursorMode

Inspect the selected initial cursor mode.

resizable : Config -> Bool

Inspect whether the window is natively resizable.

fullscreen : Config -> Bool

Inspect whether the window starts fullscreen.

exit_key : Config -> ExitKey

Inspect the selected exit key.

close_request : Config -> CloseRequest

Inspect what the window's close button does.

visible : Config -> Bool

Inspect whether the window is shown at startup.

output_dir : Config -> Str

Inspect the directory captures are written under.

recording : Config -> [NoRecording, Record(Recording)]

Inspect whether the app records itself from startup.

default_font : Config -> { path : Str, size : I32 }

Inspect the configured startup default-font intent. An empty path means the backend's built-in font.

app_id : Config -> Str

Inspect the app id. Empty means none was declared.

permissions : Config -> List(Permission)

Inspect the declared permissions, in declaration order.

Io

App.Io :: # (opaque)

Application-lifetime authority supplied by the host to init! and update!. Accessors are pure, allocation-free selections. Keep individual capabilities in helpers and task closures; effects retain their documented phase rules.

for_host : Authority -> Io

Private adapter construction. The argument cannot be manufactured by applications.

stub : Io

IO for pure tests. The host refuses every effect made through it with PermissionDenied.

files : Io -> Access

Select files authority without performing an effect.

http : Io -> Client

Select http authority without performing an effect.

commands : Io -> Runner

Select commands authority without performing an effect.

stdout : Io -> Writer

Select stdout authority without performing an effect.

stderr : Io -> Writer

Select stderr authority without performing an effect.

udp : Io -> Network

Select udp authority without performing an effect.

sqlite : Io -> Service

Select sqlite authority without performing an effect.

clipboard : Io -> Clipboard

Select clipboard authority without performing an effect.

capture : Io -> Writer

Select capture authority without performing an effect.

env : Io -> Environment

Select environment access.

exit! : Io, I32 => {}

Stop the application with the given exit code at the end of the current host cycle.

The call returns, the cycle finishes -- including its render! -- and then the host shuts down in the ordinary way: live tasks are cancelled, and a message nobody has received yet is never delivered. Called from init!, the app still runs one host cycle first.

update! usually stops by returning Err(Exit(code)); this is the spelling for init! and for a task. Legal in init!, update!, and tasks; refused in render!.

args! : Io => List(Str)

Return the complete process argument list supplied by the launcher.

The first element is argv[0], followed by application-owned arguments in order. The host removes its reserved --host-* switches before this list reaches the app. The value is stable for the process lifetime.

Legal only in init!. Keep what update! needs from it in the model. App.init_for_args is the other way to read argv, before the window exists.

entropy! : Io => U64

Draw one number from the operating system's entropy source.

This is the only thing in the platform that makes a run differ from the last one by itself, and it is deliberately the app's decision:

seed = Random.seed(U64.to_u32_wrap(io.entropy!()))

Keep the returned Random.State in the model and draw with pure Random.Generator values during update!, so the run is reproducible from its seed. A run that must reproduce writes a constant seed instead and never calls this; a run that should vary calls it once. Nothing else about the platform is affected either way, because the generator's state is the model's rather than the host's.

The entropy is real in every mode, including headless: determinism comes from an app choosing a fixed seed, not from the host quietly handing out the same "random" number on every run.

Legal only in init!.

random_i32! : Io, I32, I32 => I32

Get a varying startup number in the inclusive range [min, max].

Legal only in init!. entropy! is the one to seed a generator from: it draws on the operating system rather than on the backend's own generator, and it says what it is for. This remains for a one-off value in a range, such as a jittered start position that nothing else depends on.

suggest_window_size! : Io, { width : I32, height : I32 } => Try({}, [InvalidSize, NotSupported])

Suggest positive initial window dimensions to the window manager.

Answers Err(NotSupported) on a target whose windows cannot be resized. Call as io.suggest_window_size!(size). Legal in init!, update!, and tasks; refused in render!. A running app resizes itself with Window.suggest_size!, which makes the same host call, and only this spelling can report a refusal.

suggest_window_min_size! : Io, { width : I32, height : I32 } => {}

Suggest the smallest window size the user can drag the window down to.

Each negative dimension is clamped to 0, which leaves that axis unconstrained. The minimum only applies to a resizable window, so pair it with App.default.with_resizable(Bool.True). Call as io.suggest_window_min_size!(size). Legal in init!, update!, and tasks; refused in render!.

set_target_fps! : Io, I32 => {}

Set raylib's CPU-side frame-rate cap.

Values at or below zero render uncapped. This neither selects a software renderer nor controls VSync. Call as io.set_target_fps!(fps). Legal in init!, update!, and tasks; refused in render!. A running app changes the cap with Window.set_target_fps!.

set_exit_key! : Io, ExitKey => {}

Set which key ends the app, or NoExitKey to stop any key from ending it.

The exit key ends the app directly, before update! sees the press, whatever Config.with_close_request chose; the window's close button follows with_close_request and is unaffected by this. Call as io.set_exit_key!(NoExitKey). Legal in init!, update!, and tasks; refused in render!.

set_cursor_mode! : Io, CursorMode => {}

Apply cursor visibility and capture atomically through one tagged operation. Legal in init!, update!, and tasks; refused in render!. Mouse.set_cursor_mode! is the same change from update! or a task.

set_cursor! : Io, Cursor => {}

Set the native operating-system cursor shape. Legal in init!, update!, and tasks; refused in render!. Mouse.set_cursor! is the same change from update! or a task.

default_font! : Io => Try(Font, [PermissionDenied, AssetPathInvalid, AssetNotFound, AssetReadFailed, FontLoadFailed, ResourceLimit])

Return the configured startup font, or the backend's built-in font when none was configured. A configured path is resolved from the process working directory. The host loads it once; repeat calls return retained aliases of the same resource. Legal only in init!.

Reading a configured file needs a declared directory covering its path, such as WorkingDirectory(ReadOnly); calling this with no file declaration at all stops the app with a message naming one.

Environment

App.Environment :: # (opaque)

Opaque access to startup environment variables.

read! : Environment, Str => Try(Str, [NotFound, PermissionDenied])

Read a variable. The name must be declared with EnvVar, or covered by EnvAny; an undeclared name is PermissionDenied, and reading with no environment declaration at all stops the app as a programmer error. Legal only in init!.

InitCallback : Io => Try(model, [Exit(I64), ..errors])

Effectful startup callback run after the host has initialized raylib and audio. Return Ok(model) to start the app, Err(Exit(code)) to quit before the first frame, or let other initialization errors propagate.

ConfigForArgs : List(Str) -> Config

Pure startup configuration chosen from the complete process argv before the host creates its native window. This is where an app can opt into a hidden recording mode based on its own command-line flags.

Init : {
    config : ConfigForArgs,
    run! : InitCallback(model, errors),
}

Startup configuration paired with an effectful model initializer.