Clip
Preload input and let the box play it backBuild a sequence of per-frame input with a ClipBuilder, hand it to the ClipHandle from Device::clip(), and the box drains one entry per native frame into the same injection state your live move and inject calls feed. Playback is box-clocked, so it carries no host scheduling jitter. It's field-generic (mouse, keyboard, and media in one clip) and backs the CLIP commands.
1. build a clip with ClipBuilder
clip.move_by(10, 0)
clip.press(Button::Left)
clip.gap(20)
clip.release(...)
2. drive playback through a ClipHandle
handle = device.clip();
handle.append(&clip) ──▶ copy the entries into the box's ring
handle.start() ──▶ box plays one entry per native frame
handle.query_status() ◀── ring depth + progress + playback state
handle.stop() ──▶ stop (retained: rewind; streaming: flush)
or let the box play it on a physical key, no host round-trip:
handle.bind(ClipTrigger::new(Key::F1, Edge::Press, ClipAction::Start))clip
Open a clip handlefn clip(&self) -> ClipHandle
Returns a ClipHandle bound to this box. The handle owns the append-sequence counter the box uses to spot a dropped append, so keep one handle for a clip session (top it up through that handle) rather than reopening one per append.
Playback lives in the box's RAM: a reboot or reconnect drops the loaded clip, so re-preload after one. Its config (auto-lock, loop, retain, and the trigger set) goes too: unlike a held lock or catch subscription, a clip isn't re-asserted for you, so re-set it after a reconnect. A clip needs a cloned mouse, since its frame clock is the mouse's report tick; keyboard and media edges ride that tick.
ClipBuilder
Build the entry streamfn new() -> ClipBuilder
Each method appends one per-frame entry, so a builder is a timeline read top to bottom. Motion is a relative delta; an edge (button, key, or media) is an Action that stays held until a later frame changes it; a gap is N idle frames (the box NAKs, like an idle mouse). The methods take &mut self and return &mut Self, so chain them or push in a loop; clear() reuses the allocation.
| Method | Appends |
|---|---|
gap(frames) | N idle frames (0 is a no-op). |
move_by(dx, dy) | a cursor-motion frame. |
wheel(dz) | a wheel frame. |
press / release / force_release(usage) | a one-frame press, soft-release, or force-release of any Usage (button, key, or media), like Device::press. |
edge(usage, action) | a one-edge frame for any Usage with an explicit Action. |
frame(dx, dy, wheel, edges) | a motion delta plus up to 8 Usage / Action edges on one frame. |
press/release/force_release are wrappers over edge, which is a wrapper over frame. Reach for frame only when you need motion and edges (or several edges) on the same frame, e.g. moving while a button is held, which is how a faithful recording of "aim and hold fire" looks.
use medius::{ClipBuilder, Button, Key};
let mut clip = ClipBuilder::new();
for _ in 0..200 { clip.move_by(10, 0); } // 200 box-timed frames of +10 dx
clip.press(Button::Left) // a click, held for 20 frames
.gap(20)
.release(Button::Left);
clip.press(Key::A) // then type 'a'
.gap(3)
.release(Key::A);frame is the one builder call that fills more than a single field, so it's how a move and an edge share a frame: one entry, one wire report. Edges are sticky, so a hold is a press once then plain motion until the release.
use medius::{Action, Button, ClipBuilder};
let mut clip = ClipBuilder::new();
// move (+10, -4) AND press Left on the same frame
clip.frame(10, -4, 0, &[(Button::Left.into(), Action::Press)]);
// "aim and hold fire": press once, keep moving while held, then release
clip.frame(8, -2, 0, &[(Button::Left.into(), Action::Press)]);
for _ in 0..60 { clip.move_by(8, -2); } // Left stays down (edges are sticky)
clip.frame(0, 0, 0, &[(Button::Left.into(), Action::SoftRelease)]);ClipHandle
Fill the ring, configure, and drive playbackFrom Device::clip(). Every method below is fire-and-forget: it queues a frame and returns. The auto-lock, loop, and retain settings and the trigger set are the clip's config; the engine verbs drive playback. query_status reads the ring depth and playback state, and query_config reads the config back.
| Method | Does |
|---|---|
append(clip: &ClipBuilder) | Send a ClipBuilder's entries to the ring; splits a large clip into whole-entry frames with contiguous append seqs. |
set_autolock(scope: &[Blanket]) | Which input groups to lock while playing (clip-owned, released on stop). |
set_loop(on: bool) | Loop playback at the clip end (retained mode only). |
set_retain(on: bool) | Retain the clip so it can rewind and replay (false = streaming, the default). Set before the first append. |
finalize() | Close a retained clip: fix its end so it can replay and loop. |
| Method | Does |
|---|---|
bind(trigger: ClipTrigger) | Add or overwrite a binding: a physical edge drives an action on the box, no host round-trip. |
unbind(usage, edge: Edge) | Remove the binding on that usage and edge. |
clear_triggers() | Remove every binding. |
| Method | Does |
|---|---|
start() | Rewind to the clip start and play (resume from a pause). |
stop() | Stop, release held input and the clip lock; a streaming clip flushes, a retained clip rewinds and is kept. |
pause() / resume() | Halt mid-clip keeping the cursor and held input / continue from the paused cursor. |
restart() | Force a rewind and play, even mid-playback. |
toggle() | Play if idle/paused, stop if playing. |
clear() | Discard the loaded clip, free the ring, clear a fault. |
A dropped append (or an overflow) leaves the clip ClipState::Faulted and stops it. Recover with clear and rebuild, not by appending more; a faulted stream has a hole in it.
Streaming and retained
Drain-and-discard, or keep-and-replayA clip runs in one of two shapes, chosen by set_retain before the first append.
| Aspect | Streaming (default) | Retained |
|---|---|---|
| Turn on | nothing, it's the default | set_retain(true) before the first append |
| The ring | each entry is freed as it plays, so it's unbounded | entries are kept after playing, up to 64 KiB |
| Top up mid-play | yes, append to the tail in real time | append until finalize, then it's sealed |
| Replay | no, it plays once | yes, it rewinds and replays |
loop | not available | available once finalized |
stop | flushes the buffer | rewinds and keeps the clip |
| Best for | open-ended or generated input | a fixed macro you replay on a trigger |
streaming (drain-and-discard)
append ──▶ [ e4 e3 e2 ] ──▶ play ──▶ freed (unbounded, top up forever)
box reclaims each entry once played; no replay
retained (keep-and-replay, up to 64 KiB)
append ──▶ [ e0 e1 e2 e3 e4 ] ──▶ finalize ──▶ sealed
base │──▶ play cursor ──▶ end
└──── start / loop rewinds to base ◀────┘set_retain only takes effect before the first append, and an append after finalize is rejected. To reload a retained clip, clear it and build again.
// Streaming: preload, play with auto-lock, then top up in real time, pacing against free.
use medius::{Blanket, ClipState};
use std::time::Duration;
let handle = device.clip(); // device: an open Device
handle.set_autolock(&[Blanket::Aim])?; // lock only the aim axes while playing
handle.append(&clip)?; // preload (clip, next_chunk: ClipBuilders you built)
handle.start()?;
loop {
let s = handle.query_status()?;
if s.state == ClipState::Idle { break; } // done, or stopped
if s.free as usize > next_chunk.as_bytes().len() {
handle.append(&next_chunk)?; // stream more while there's room
}
std::thread::sleep(Duration::from_millis(5));
}
handle.stop()?;Triggers
Play, stop, or toggle on a physical keyA ClipTrigger binds one Edge of a usage to one ClipAction, so the box drives playback itself with no host round-trip.
| Part | Is | Example |
|---|---|---|
| usage | the button, key, or media the edge is on (or any of a class) | Key::F1 |
| edge | which transition fires it: press, release, or both | Edge::Press |
| action | the engine verb to run | ClipAction::Start |
Bindings are a managed set keyed by (usage, edge), like a lock: bind adds or overwrites, unbind drops one, and clear_triggers wipes them. A physical edge runs the one most-specific match, so a binding on Key::F1 beats an any-key binding.
| To get | Bind |
|---|---|
| Hold to play | F1 Press → Start and F1 Release → Stop |
| Toggle play/stop on one key | Side1 Press → Toggle |
| Separate play and stop keys | F1 Press → Start and F2 Press → Stop |
| Pause, then resume | F3 Press → Pause and F4 Press → Resume |
.consume() swallows the triggering edge from the game for the length of the hold, so the key drives the clip without also reaching what you're playing.
use medius::{Button, ClipAction, ClipTrigger, Edge, Key};
let clip = device.clip();
clip.set_retain(true)?; // set the mode before loading
clip.append(&recording)?;
clip.finalize()?; // close it so it can replay
// Hold-to-play: F1 down starts, F1 up stops. Consume F1 so the game never sees it.
clip.bind(ClipTrigger::new(Key::F1, Edge::Press, ClipAction::Start).consume())?;
clip.bind(ClipTrigger::new(Key::F1, Edge::Release, ClipAction::Stop).consume())?;
// Or one side-button that toggles play/stop:
clip.bind(ClipTrigger::new(Button::Side1, Edge::Press, ClipAction::Toggle))?;On AsyncDevice
AsyncClipHandle: control fires, queries awaitAsyncDevice::clip() returns an AsyncClipHandle that keeps append, the settings, and the engine verbs synchronous (they just queue a frame), while query_status().await and query_config().await are futures like the other queries.
let device = Device::find()?.into_async();
let handle = device.clip();
handle.append(&clip)?; // sync, no await
handle.start()?; // sync
let s = handle.query_status().await?; // the query awaits