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.
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.
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.
:= {
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.
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.
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")])
Inspect the window title.
Inspect the initial logical window dimensions.
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.
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.
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.
Inspect the app id. Empty means none was declared.
permissions : Config -> List(Permission)
Inspect the declared permissions, in declaration order.
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.
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!.
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.
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.
App.Environment :: # (opaque)
Opaque access to startup environment variables.
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.