input source

← input · API reference · version 0.2.6

3 file(s), 716 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
MB_LEFT const53—101 104 106 112 113 115 … (15 total)
MB_RIGHT const54—105 112 114 116 123 169 … (13 total)
MB_MIDDLE const55—none in this package
ActionBinding struct60—78 34 35 130 159 53 … (9 total)
ab_name const61—203 218 232 34 35 130 … (12 total)
ab_keys const62—156 204 219 233 34 35 … (12 total)
AxisBinding struct68—79 38 57
ax_name const69—247 38 57
ax_neg const70—163 164 248 38 57 139
ax_pos const71—166 167 249 38 57 137
Bindings struct77—91 106 130 32 128 157 … (9 total)
bnd_actions const78—155 202 217 231 33 129 … (11 total)
bnd_axes const79—162 246 37 132 161 56 … (8 total)
InputState struct90—106 113 130 137 153 182 … (19 total)
input_new fn106—41 134 60
input_set_bindings fn130INP-004163 210 235
input_tick fn153INP-001none in this package
input_tick_from_state fn181INP-00148 54 60 66 78 82 … (33 total)
is_action_pressed fn201INP-00249 55 61 70 137 141 … (18 total)
is_action_just_pressed fn216INP-00150 56 62 71 138 145 … (14 total)
is_action_just_released fn230INP-00151 57 63 67 91
get_axis fn245INP-00279 83 87 91 94 129 … (10 total)
mouse_x fn259—102 171
mouse_y fn261—103 171
mouse_button_down fn266INP-003104 105 113 114 121 168 … (9 total)
mouse_button_just_pressed fn273INP-003106 115 116 170 180 184
mouse_button_just_released fn281—122 123 189 191
mouse_wheel fn289—107 117 104 106

src/input.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// input — abstract input state + key bindings for loft games.
//
// (This library was parked from 2026-06-01 to 2026-06-04 on two loft
// compiler bugs — a cross-package constructor landing in CONST_STORE
// (loft#248) and a nested `&self` method call whose writes did not
// persist on the interpreter (loft#266).  Both are fixed and the test
// gate is gone; the suite runs on the interpreter and `--native`.)
//
// Wraps the polled input primitives exposed by the `graphics` library
// (`gl_key_pressed`, `gl_mouse_x`/`gl_mouse_y`, `gl_mouse_button`,
// `gl_mouse_wheel`) into:
//
//   - **Action bindings**: map a high-level action name (e.g. "jump")
//     to one or more key codes — game code queries `is_action_pressed`
//     instead of hardcoding `gl_key_pressed(KEY_SPACE)`.
//   - **Edge detection**: `is_action_just_pressed` returns true on the
//     exact tick a binding went from up to down (single-tick edge);
//     same for `mouse_button_just_pressed`.  Built by keeping
//     last-frame state alongside this-frame state.
//   - **Axis abstraction**: pair of keys → -1.0 / 0.0 / +1.0 (e.g.
//     "horizontal" binds Left=-1 / Right=+1; query with `get_axis`).
//
// Per-tick contract:
//
//     state.input_tick();           // poll graphics primitives
//     if state.is_action_just_pressed("jump") { ... }
//     vx = state.get_axis("horizontal");
//
// Headless / replay / test usage: `input_tick_from_state` advances
// the state from a caller-supplied snapshot instead of polling
// graphics — the same edge-detection logic runs on either input
// source, so tests don't need a real GL context.
//
// `input_tick` is the only function CI cannot exercise — it polls a live
// GL context.  Everything else runs headless through
// `input_tick_from_state`, which takes the same snapshot a replay or a
// network rollback would feed it.

use graphics::*;

// Maximum key code the library tracks.  Graphics's highest constant
// today is `KEY_F12 = 146`; 256 leaves room for future additions and
// matches the byte-indexable shape of a typical keyboard scan-code
// table.  Bindings referencing a key code >= this are silently
// ignored at query time (a `pressed` query for an out-of-range
// binding returns false).
KEY_MAX = 256;

// Mouse-button bitmask values (mirrors `graphics::gl_mouse_button`).
pub const MB_LEFT   = 1;
pub const MB_RIGHT  = 2;
pub const MB_MIDDLE = 4;

// One named action: "jump" → [KEY_SPACE, KEY_UP] (any of the listed
// keys triggers the action).  An empty `ab_keys` is degenerate but
// valid — the action will simply never fire.
pub struct ActionBinding {
  pub const ab_name: text,
  pub const ab_keys: vector<integer>
}

// One named axis: pair of (negative key, positive key) → -1.0 / 0.0
// / +1.0.  If both are down simultaneously the axis reads 0.0 (they
// cancel) — same convention as Unity / Godot's built-in axis pairs.
pub struct AxisBinding {
  pub const ax_name: text,
  pub const ax_neg: integer,
  pub const ax_pos: integer
}

// Full binding set — game code constructs this once and passes it
// into `input_new`.  Re-binding at runtime: mutate this struct in
// place (or build a new one) and call `input_set_bindings`.
pub struct Bindings {
  pub const bnd_actions: vector<ActionBinding>,
  pub const bnd_axes: vector<AxisBinding>
}

// Abstract input state.  Fields are documented but considered
// opaque — query via the `is_action_*` / `get_axis` / `mouse_*`
// functions below.
//
// `is_keys_now[i]` is true if key code `i` is currently down;
// `is_keys_prev[i]` holds the same flag from the previous tick so
// edge detection compares the two.  Mouse buttons are a bitmask
// (`MB_LEFT | MB_RIGHT | …`).
pub struct InputState {
  pub is_bindings: Bindings,
  const is_keys_now: vector<boolean>,
  const is_keys_prev: vector<boolean>,
  is_mouse_x: float,
  is_mouse_y: float,
  is_mouse_buttons_now: integer,
  is_mouse_buttons_prev: integer,
  is_mouse_wheel: integer
}

// Construct an `InputState` from a `Bindings` set.  Both key tables
// start fully released; the first call to `input_tick` populates
// `is_keys_now` (and `is_keys_prev` stays all-false, so any key down
// on the first tick fires its `just_pressed` edge — the documented
// "first frame counts as a transition" semantics).
pub fn input_new(b: Bindings) -> InputState {
  keys_now: vector<boolean> = [];
  keys_prev: vector<boolean> = [];
  for _ in 0..KEY_MAX {
    keys_now += [false];
    keys_prev += [false];
  }
  InputState{
    is_bindings: b,
    is_keys_now: keys_now,
    is_keys_prev: keys_prev,
    is_mouse_x: 0.0,
    is_mouse_y: 0.0,
    is_mouse_buttons_now: 0,
    is_mouse_buttons_prev: 0,
    is_mouse_wheel: 0
  }
}

// Replace the binding set on a live state (e.g. after the player
// remaps keys in a settings menu).  Does NOT clear the key tables —
// in-flight key presses survive the rebind so a remap doesn't
// strand an already-held key.
// Example: @INP-004
pub fn input_set_bindings(self: &InputState, b: Bindings) {
  self.is_bindings = b;
}

// Internal: shift `is_keys_now → is_keys_prev` and zero `is_keys_now`
// in preparation for the next poll.  Same for mouse buttons.  Public
// surface uses `input_tick` (or `input_tick_from_state` for tests).
fn advance_tick(self: &InputState) {
  for i in 0..KEY_MAX {
    self.is_keys_prev[i] = self.is_keys_now[i];
    self.is_keys_now[i] = false;
  }
  self.is_mouse_buttons_prev = self.is_mouse_buttons_now;
  self.is_mouse_buttons_now = 0;
  self.is_mouse_wheel = 0;
}

// Poll the graphics input primitives and refresh state for one tick.
// Reads `gl_key_pressed` for every key code referenced by the
// current bindings (avoids a 256-call sweep — only ask about keys
// the game actually cares about), plus `gl_mouse_x`/`gl_mouse_y`/
// `gl_mouse_button`/`gl_mouse_wheel` unconditionally.
// Example: @INP-001
pub fn input_tick(self: &InputState) {
  self.advance_tick();
  for ab in self.is_bindings.bnd_actions {
    for k in ab.ab_keys {
      if k >= 0 && k < KEY_MAX {
        self.is_keys_now[k] = graphics::gl_key_pressed(k);
      }
    }
  }
  for ax in self.is_bindings.bnd_axes {
    if ax.ax_neg >= 0 && ax.ax_neg < KEY_MAX {
      self.is_keys_now[ax.ax_neg] = graphics::gl_key_pressed(ax.ax_neg);
    }
    if ax.ax_pos >= 0 && ax.ax_pos < KEY_MAX {
      self.is_keys_now[ax.ax_pos] = graphics::gl_key_pressed(ax.ax_pos);
    }
  }
  self.is_mouse_x = graphics::gl_mouse_x();
  self.is_mouse_y = graphics::gl_mouse_y();
  self.is_mouse_buttons_now = graphics::gl_mouse_button();
  self.is_mouse_wheel = graphics::gl_mouse_wheel();
}

// Advance state from a caller-supplied snapshot instead of polling
// `graphics`.  Used by tests (no GL context) and by network replay
// / record-playback systems.  `keys` length is clamped to KEY_MAX;
// out-of-range entries are ignored.
// Example: @INP-001
pub fn input_tick_from_state(
  self: &InputState,
  keys: const vector<boolean>,
  mx: float, my: float,
  mbtn: integer, wheel: integer
) {
  self.advance_tick();
  n = len(keys);
  if n > KEY_MAX { n = KEY_MAX; }
  for i in 0..n {
    self.is_keys_now[i] = keys[i];
  }
  self.is_mouse_x = mx;
  self.is_mouse_y = my;
  self.is_mouse_buttons_now = mbtn;
  self.is_mouse_wheel = wheel;
}

// True if ANY of the action's bound keys is currently down.
// Example: @INP-002
pub fn is_action_pressed(self: const InputState, action: text) -> boolean {
  for ab in self.is_bindings.bnd_actions {
    if ab.ab_name != action { continue; }
    for k in ab.ab_keys {
      if k >= 0 && k < KEY_MAX && self.is_keys_now[k] { return true; }
    }
    return false;
  }
  false
}

// True for exactly one tick: the moment any of the action's bound
// keys transitions from up to down.  If multiple bound keys all
// transition on the same tick it still fires once.
// Example: @INP-001
pub fn is_action_just_pressed(self: const InputState, action: text) -> boolean {
  for ab in self.is_bindings.bnd_actions {
    if ab.ab_name != action { continue; }
    for k in ab.ab_keys {
      if k >= 0 && k < KEY_MAX && self.is_keys_now[k] && !self.is_keys_prev[k] { return true; }
    }
    return false;
  }
  false
}

// True for exactly one tick: the moment any of the action's bound
// keys transitions from down to up.
// Example: @INP-001
pub fn is_action_just_released(self: const InputState, action: text) -> boolean {
  for ab in self.is_bindings.bnd_actions {
    if ab.ab_name != action { continue; }
    for k in ab.ab_keys {
      if k >= 0 && k < KEY_MAX && !self.is_keys_now[k] && self.is_keys_prev[k] { return true; }
    }
    return false;
  }
  false
}

// Read an axis pair: -1.0 if only `ax_neg` is down, +1.0 if only
// `ax_pos` is down, 0.0 if neither or both.  Unknown axis name
// returns 0.0.
// Example: @INP-002
pub fn get_axis(self: const InputState, axis: text) -> float {
  for ax in self.is_bindings.bnd_axes {
    if ax.ax_name != axis { continue; }
    neg = ax.ax_neg >= 0 && ax.ax_neg < KEY_MAX && self.is_keys_now[ax.ax_neg];
    pos = ax.ax_pos >= 0 && ax.ax_pos < KEY_MAX && self.is_keys_now[ax.ax_pos];
    if neg && !pos { return -1.0; }
    if pos && !neg { return 1.0; }
    return 0.0;
  }
  0.0
}

// Mouse position — pixels from the top-left of the window
// (matches `graphics::gl_mouse_x` / `gl_mouse_y`).
pub fn mouse_x(self: const InputState) -> float { self.is_mouse_x }
// The pointer's y this frame, the companion of `mouse_x`.
pub fn mouse_y(self: const InputState) -> float { self.is_mouse_y }

// True if the given mouse button (`MB_LEFT` / `MB_RIGHT` / `MB_MIDDLE`)
// is currently down.  Pass the constant, not a bitmask of multiple.
// Example: @INP-003
pub fn mouse_button_down(self: const InputState, button: integer) -> boolean {
  (self.is_mouse_buttons_now & button) != 0
}

// True for exactly one tick — the moment the button transitions
// from up to down.
// Example: @INP-003
pub fn mouse_button_just_pressed(self: const InputState, button: integer) -> boolean {
  now_down = (self.is_mouse_buttons_now & button) != 0;
  prev_down = (self.is_mouse_buttons_prev & button) != 0;
  now_down && !prev_down
}

// True for exactly one tick — the moment the button transitions
// from down to up.
pub fn mouse_button_just_released(self: const InputState, button: integer) -> boolean {
  now_down = (self.is_mouse_buttons_now & button) != 0;
  prev_down = (self.is_mouse_buttons_prev & button) != 0;
  prev_down && !now_down
}

// Accumulated scroll-wheel delta since the last `input_tick` —
// positive = scroll up.  Mirrors `graphics::gl_mouse_wheel`.
pub fn mouse_wheel(self: const InputState) -> integer { self.is_mouse_wheel }

tests/01-basics.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Smoke test for the input library.  Drives state via
// `input_tick_from_state` (no GL context needed); validates:
//   - construction from bindings
//   - pressed / just_pressed / just_released for actions
//   - axis pair direction + cancellation
//   - mouse button bitmask + edge detection
//   - unknown action / axis names return safe defaults
//
// Action coverage: every public symbol of the library is exercised
// at least once here.

use input::*;

KEY_SPACE = 32;
KEY_LEFT  = 130;
KEY_RIGHT = 131;
KEY_A     = 97;

// Build a snapshot of length KEY_MAX with `down` set on the given
// keys.  Mirrors the shape `input_tick_from_state` expects.
fn snap(keys: vector<integer>) -> vector<boolean> {
  v: vector<boolean> = [];
  for _ in 0..256 { v += [false]; }
  for k in keys { if k >= 0 && k < 256 { v[k] = true; } }
  v
}

fn make_state() -> input::InputState {
  b = input::Bindings{
    bnd_actions: [
      input::ActionBinding{ab_name: "jump", ab_keys: [KEY_SPACE]},
      input::ActionBinding{ab_name: "use",  ab_keys: [KEY_A]}
    ],
    bnd_axes: [
      input::AxisBinding{ax_name: "horizontal", ax_neg: KEY_LEFT, ax_pos: KEY_RIGHT}
    ]
  };
  input::input_new(b)
}

fn test_pressed_and_edges() {
  s = make_state();

  // Tick 1: SPACE down — pressed AND just_pressed (rises from idle).
  s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(s.is_action_pressed("jump"), "tick1: jump pressed");
  assert(s.is_action_just_pressed("jump"), "tick1: jump just pressed");
  assert(!s.is_action_just_released("jump"), "tick1: jump NOT just released");

  // Tick 2: SPACE still down — pressed but no edge.
  s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(s.is_action_pressed("jump"), "tick2: jump still pressed");
  assert(!s.is_action_just_pressed("jump"), "tick2: jump NOT just pressed");
  assert(!s.is_action_just_released("jump"), "tick2: jump NOT just released");

  // Tick 3: SPACE released — just_released fires once.
  s.input_tick_from_state(snap([]), 0.0, 0.0, 0, 0);
  assert(!s.is_action_pressed("jump"), "tick3: jump released");
  assert(!s.is_action_just_pressed("jump"), "tick3: jump NOT just pressed");
  assert(s.is_action_just_released("jump"), "tick3: jump just released");

  // Tick 4: idle — no edges.
  s.input_tick_from_state(snap([]), 0.0, 0.0, 0, 0);
  assert(!s.is_action_just_released("jump"), "tick4: edge cleared");

  // Unknown action name — all queries return safe defaults.
  assert(!s.is_action_pressed("nope"), "unknown action !pressed");
  assert(!s.is_action_just_pressed("nope"), "unknown action !just_pressed");
}

fn test_axis_pair() {
  s = make_state();

  // Neutral: zero.
  s.input_tick_from_state(snap([]), 0.0, 0.0, 0, 0);
  assert(s.get_axis("horizontal") == 0.0, "axis neutral=0");

  // Right only: +1.
  s.input_tick_from_state(snap([KEY_RIGHT]), 0.0, 0.0, 0, 0);
  assert(s.get_axis("horizontal") == 1.0, "axis right=+1");

  // Left only: -1.
  s.input_tick_from_state(snap([KEY_LEFT]), 0.0, 0.0, 0, 0);
  assert(s.get_axis("horizontal") == -1.0, "axis left=-1");

  // Both: cancel to 0.
  s.input_tick_from_state(snap([KEY_LEFT, KEY_RIGHT]), 0.0, 0.0, 0, 0);
  assert(s.get_axis("horizontal") == 0.0, "axis both=cancel");

  // Unknown axis name: 0.0.
  assert(s.get_axis("vertical") == 0.0, "unknown axis=0");
}

fn test_mouse() {
  s = make_state();

  // Tick 1: left button down, mouse at (10, 20).
  s.input_tick_from_state(snap([]), 10.0, 20.0, input::MB_LEFT, 3);
  assert(s.mouse_x() == 10.0, "mouse_x");
  assert(s.mouse_y() == 20.0, "mouse_y");
  assert(s.mouse_button_down(input::MB_LEFT), "left button down");
  assert(!s.mouse_button_down(input::MB_RIGHT), "right NOT down");
  assert(s.mouse_button_just_pressed(input::MB_LEFT), "left just pressed");
  assert(s.mouse_wheel() == 3, "wheel=+3");

  // Tick 2: left still down + right also down — left edge cleared,
  // right edge fires.
  s.input_tick_from_state(snap([]), 10.0, 20.0,
    input::MB_LEFT | input::MB_RIGHT, 0);
  assert(s.mouse_button_down(input::MB_LEFT), "left still down");
  assert(s.mouse_button_down(input::MB_RIGHT), "right now down");
  assert(!s.mouse_button_just_pressed(input::MB_LEFT), "left edge cleared");
  assert(s.mouse_button_just_pressed(input::MB_RIGHT), "right just pressed");
  assert(s.mouse_wheel() == 0, "wheel resets per tick");

  // Tick 3: all released — just_released fires for both.
  s.input_tick_from_state(snap([]), 10.0, 20.0, 0, 0);
  assert(!s.mouse_button_down(input::MB_LEFT), "left released");
  assert(s.mouse_button_just_released(input::MB_LEFT), "left just released");
  assert(s.mouse_button_just_released(input::MB_RIGHT), "right just released");
}

fn test_multi_key_action() {
  // Action bound to MULTIPLE keys — pressing either fires it.
  b = input::Bindings{
    bnd_actions: [
      input::ActionBinding{ab_name: "fire", ab_keys: [KEY_SPACE, KEY_A]}
    ],
    bnd_axes: []
  };
  s = input::input_new(b);

  s.input_tick_from_state(snap([KEY_A]), 0.0, 0.0, 0, 0);
  assert(s.is_action_pressed("fire"), "fire via A");
  assert(s.is_action_just_pressed("fire"), "fire edge via A");

  s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(s.is_action_pressed("fire"), "fire via SPACE");
  // A went up, SPACE went down — both transitions in one tick.  The
  // SPACE-down transition fires `just_pressed` even though the
  // action was held last tick (different key, different transition).
  assert(s.is_action_just_pressed("fire"), "fire edge via SPACE");
}

fn test_rebind() {
  s = make_state();

  // SPACE down → jump fires.
  s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(s.is_action_pressed("jump"), "pre-rebind jump pressed");

  // Rebind: "jump" now needs A instead.  SPACE is still held but no
  // longer counts.
  new_b = input::Bindings{
    bnd_actions: [
      input::ActionBinding{ab_name: "jump", ab_keys: [KEY_A]}
    ],
    bnd_axes: []
  };
  s.input_set_bindings(new_b);
  // Re-tick at the SAME hardware state — SPACE no longer triggers.
  s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(!s.is_action_pressed("jump"), "post-rebind: SPACE no longer triggers");

  s.input_tick_from_state(snap([KEY_A]), 0.0, 0.0, 0, 0);
  assert(s.is_action_pressed("jump"), "post-rebind: A triggers");
}

fn main() {
  test_pressed_and_edges();
  test_axis_pair();
  test_mouse();
  test_multi_key_action();
  test_rebind();
  println("input lib: all checks passed");
}

tests/02-worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// One test per non-obvious contract, and each one is that contract's
// worked EXAMPLE.
//
// ⚠⚠ **The pair is the deliverable.**  A snippet in a doc comment rots
// silently — nothing compiles it, so it goes stale at the first
// signature change and the reader finds out.  A test nobody links to is
// correct and invisible.  So every function below carries an
// `// Example: @INP-0NN` line pointing here, and every tag here is
// reachable from the functions it demonstrates.
//
// ⚠ These are examples FIRST and assertions second: each is written the
// way a game's frame loop would actually write it.  `01-basics` already
// pins that each query answers correctly; what these pin is the shape of
// a CORRECT CALL.
//
// Why this library owes them: **an edge is a difference between two
// ticks, and a name is a bare `text`.**  Nothing in a signature says how
// often to tick, that a misspelt action is silently never pressed, that
// a combined button mask means "either", or that a rebind inherits the
// keys already held.  Every one of those is a well-typed call answering
// the same `false` / `0.0` a correct one answers.
//
// Every example drives the state through `input_tick_from_state`, so
// they run headless — no GL context, and the same edge-detection code
// path a polled game takes.
use input::*;

// Key codes as `graphics` numbers them (`KEY_A` = 97, `KEY_SPACE` = 32,
// `KEY_LEFT` = 130, `KEY_RIGHT` = 131).  A game reads them from
// `graphics`; the tests restate them so they need no GL build.
KEY_SPACE = 32;
KEY_LEFT  = 130;
KEY_RIGHT = 131;
KEY_A     = 97;
KEY_B     = 98;

// One frame's worth of keyboard, as `input_tick_from_state` wants it:
// a flag per key code, index = code.
fn keys_down(kd_codes: vector<integer>) -> vector<boolean> {
  kd_v: vector<boolean> = [];
  for _ in 0..256 { kd_v += [false]; }
  for kd_k in kd_codes { if kd_k >= 0 && kd_k < 256 { kd_v[kd_k] = true; } }
  kd_v
}

// A small platformer's bindings: two actions and one axis.
fn platformer_state() -> input::InputState {
  ps_b = input::Bindings{
    bnd_actions: [
      input::ActionBinding{ab_name: "jump", ab_keys: [KEY_SPACE]},
      input::ActionBinding{ab_name: "use",  ab_keys: [KEY_A]}
    ],
    bnd_axes: [
      input::AxisBinding{ax_name: "horizontal", ax_neg: KEY_LEFT, ax_pos: KEY_RIGHT}
    ]
  };
  input::input_new(ps_b)
}

// The frame loop, and the one rule it has to obey.
//
// ⚠⚠ **Tick exactly once per frame.**  An edge is the difference between
// the tick's table and the previous tick's, so the TICK is what consumes
// it — not the query.  Ask as many times as you like; tick twice in one
// frame and the press is gone, with nothing anywhere reporting it.
// @INP-001 — the edge lives between two ticks, so tick once per frame
fn test_a_frame_loop_ticks_once() {
  fl_s = platformer_state();

  // Frame 1 — the player is holding jump as the level starts.  Both
  // tables begin all-false, so the first tick counts as a transition:
  // an already-held key fires its edge on frame one, by design.
  fl_s.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(fl_s.is_action_just_pressed("jump"), "frame 1: the edge fires");

  // ⚠ Queries are free and repeatable — two systems can both ask.
  assert(fl_s.is_action_just_pressed("jump"), "a second reader lost the edge");
  assert(fl_s.is_action_pressed("jump"), "held, as well as risen");

  // Frame 2 — still held: pressed, but no edge.  This is what makes
  // `just_pressed` the right query for "fire once per press".
  fl_s.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(fl_s.is_action_pressed("jump"), "frame 2: still held");
  assert(!fl_s.is_action_just_pressed("jump"), "frame 2: the edge is spent");

  // Frame 3 — released: the falling edge, once.
  fl_s.input_tick_from_state(keys_down([]), 0.0, 0.0, 0, 0);
  assert(fl_s.is_action_just_released("jump"), "frame 3: the release fires");
  assert(!fl_s.is_action_pressed("jump"), "frame 3: no longer held");

  // ⚠ The failure this door exists to prevent: a second tick inside one
  // frame — a menu that polls, or a fixed-step loop that ticks input
  // alongside every simulation step — hands the press to nobody.
  fl_lost = platformer_state();
  fl_lost.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
  fl_lost.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
  assert(!fl_lost.is_action_just_pressed("jump"),
         "the second tick in one frame swallowed the press");
  // The wheel is a per-tick delta and is zeroed the same way.
  fl_lost.input_tick_from_state(keys_down([]), 0.0, 0.0, 0, 4);
  assert(fl_lost.mouse_wheel() == 4, "wheel this tick");
  fl_lost.input_tick_from_state(keys_down([]), 0.0, 0.0, 0, 0);
  assert(fl_lost.mouse_wheel() == 0, "the wheel does not accumulate across ticks");
}

// Querying an action and an axis — and what a typo does.
//
// ⚠⚠ **A name is a bare `text` that is never declared anywhere.**  An
// unknown action answers `false`, which is exactly what an action that
// is simply not pressed answers; an unknown axis answers `0.0`, which is
// exactly what a centred one answers.  So a misspelt binding is not an
// error, it is a control that never works, for the life of the build.
// @INP-002 — every failure answers what success answers
fn test_a_misspelt_name_is_silent() {
  sn_s = platformer_state();
  sn_s.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);

  assert(sn_s.is_action_pressed("jump"), "the bound name works");

  // ⚠ These two lines are indistinguishable at the call site:
  assert(!sn_s.is_action_pressed("jmup"), "a typo answers false");
  assert(!sn_s.is_action_pressed("use"), "an unpressed action answers false");
  assert(!sn_s.is_action_just_pressed("jmup"), "and so does its edge");

  // ⚠ Same for an axis, where `0.0` carries three different meanings:
  assert(sn_s.get_axis("horizonal") == 0.0, "unknown axis reads centred");
  assert(sn_s.get_axis("horizontal") == 0.0, "neither key down reads centred");
  sn_s.input_tick_from_state(keys_down([KEY_LEFT, KEY_RIGHT]), 0.0, 0.0, 0, 0);
  assert(sn_s.get_axis("horizontal") == 0.0, "both keys down CANCEL to centred");

  // ...so `get_axis(a) == 0.0` can never be read as "the player is not
  // steering".  The values that do mean something are the poles:
  sn_s.input_tick_from_state(keys_down([KEY_RIGHT]), 0.0, 0.0, 0, 0);
  assert(sn_s.get_axis("horizontal") == 1.0, "ax_pos is +1");
  sn_s.input_tick_from_state(keys_down([KEY_LEFT]), 0.0, 0.0, 0, 0);
  assert(sn_s.get_axis("horizontal") == -1.0, "ax_neg is -1");

  // The check that does hold, if a typo must be caught: ask the bindings.
  assert(has_action(sn_s, "jump"), "jump is bound");
  assert(!has_action(sn_s, "jmup"), "jmup is not");
}

// The lookup the example above recommends when a name comes from config or a
// remap screen: the binding list is the only place a name exists.
fn has_action(ha_s: const input::InputState, ha_name: text) -> boolean {
  for ha_ab in ha_s.is_bindings.bnd_actions {
    if ha_ab.ab_name == ha_name { return true; }
  }
  false
}

// Reading mouse buttons.
//
// ⚠⚠ **A button argument is a MASK and the test is `& != 0`, so passing
// two bits asks "either", not "both".**  `MB_LEFT | MB_RIGHT` is a
// well-typed integer that reads true while only the left is down — and
// its `just_pressed` then MISSES the right button entirely, because the
// mask was already non-zero.  Pass one constant per call.
// @INP-003 — a combined button mask means "either", and hides an edge
fn test_one_button_constant_per_call() {
  mb_s = platformer_state();

  // Frame 1 — the left button goes down.
  mb_s.input_tick_from_state(keys_down([]), 12.0, 34.0, input::MB_LEFT, 0);
  assert(mb_s.mouse_button_down(input::MB_LEFT), "left is down");
  assert(!mb_s.mouse_button_down(input::MB_RIGHT), "right is not");
  assert(mb_s.mouse_button_just_pressed(input::MB_LEFT), "left's edge");
  assert(mb_s.mouse_x() == 12.0 && mb_s.mouse_y() == 34.0, "pointer, top-left origin");

  // ⚠ Only the left is down, and the combined mask says yes:
  assert(mb_s.mouse_button_down(input::MB_LEFT | input::MB_RIGHT),
         "a combined mask answers `either`, not `both`");

  // Frame 2 — the right joins while the left stays down.
  mb_s.input_tick_from_state(keys_down([]), 12.0, 34.0,
                             input::MB_LEFT | input::MB_RIGHT, 0);
  assert(mb_s.mouse_button_just_pressed(input::MB_RIGHT),
         "asked per button, the new press is seen");
  // ⚠ ...and asked as a mask, it is not: the mask was already non-zero
  // last tick, so `now && !prev` is false and the click is dropped.
  assert(!mb_s.mouse_button_just_pressed(input::MB_LEFT | input::MB_RIGHT),
         "the combined mask hid the right-button press");

  // Frame 3 — the left lifts, the right stays.
  mb_s.input_tick_from_state(keys_down([]), 12.0, 34.0, input::MB_RIGHT, 0);
  assert(mb_s.mouse_button_just_released(input::MB_LEFT), "left's release");
  assert(mb_s.mouse_button_down(input::MB_RIGHT), "right still held");
  assert(!mb_s.mouse_button_just_released(input::MB_RIGHT), "right did not release");
}

// The settings screen: the player remaps a control mid-game.
//
// ⚠⚠ **`input_set_bindings` swaps the names, never the key tables.**
// That is what keeps a key held across a remap from being stranded —
// and it means the new binding INHERITS both tables, so an action bound
// onto an already-held key reads pressed at once, and fires a
// `just_pressed` edge the player never made for it.
// @INP-004 — a rebind inherits the keys already held, edges included
fn test_a_remap_inherits_the_held_keys() {
  rb_s = platformer_state();

  // The player is holding A, which is "use".
  rb_s.input_tick_from_state(keys_down([KEY_A]), 0.0, 0.0, 0, 0);
  assert(rb_s.is_action_just_pressed("use"), "A rose for `use`");

  // They remap "jump" onto A while still holding it.
  rb_s.input_set_bindings(input::Bindings{
    bnd_actions: [input::ActionBinding{ab_name: "jump", ab_keys: [KEY_A]}],
    bnd_axes: []
  });

  // ⚠ `jump` is held immediately — the intended half: a key down across
  // the remap is not stranded until the player lets go and presses again.
  assert(rb_s.is_action_pressed("jump"), "the held key carries into the new binding");
  // ⚠ ...and the unintended half: `jump` also fires its rising edge on
  // this tick, off a press aimed at `use`.  Apply a remap on a frame
  // where the key is up, or absorb one frame of edges after applying it.
  assert(rb_s.is_action_just_pressed("jump"), "the new binding inherited the edge");

  // The next tick with A still down settles it: held, no edge.
  rb_s.input_tick_from_state(keys_down([KEY_A]), 0.0, 0.0, 0, 0);
  assert(rb_s.is_action_pressed("jump") && !rb_s.is_action_just_pressed("jump"),
         "one frame later the inherited edge is spent");

  // ⚠ How complete the inherited table is depends on WHICH tick door ran.
  // `input_tick_from_state` copies the whole snapshot, so even a key no
  // binding mentions is recorded and a rebind onto it reads pressed at
  // once.  `input_tick` polls only the keys the bindings name, so the
  // same rebind reads false there until the next tick refreshes it.
  rb_u = platformer_state();
  rb_u.input_tick_from_state(keys_down([KEY_B]), 0.0, 0.0, 0, 0);   // B binds to nothing
  rb_u.input_set_bindings(input::Bindings{
    bnd_actions: [input::ActionBinding{ab_name: "crouch", ab_keys: [KEY_B]}],
    bnd_axes: []
  });
  assert(rb_u.is_action_pressed("crouch"), "the snapshot door recorded the unbound key");
}

fn main() {
  test_a_frame_loop_ticks_once();
  test_a_misspelt_name_is_silent();
  test_one_button_constant_per_call();
  test_a_remap_inherits_the_held_keys();
  println("input worked examples passed");
}