Medius - Rust LibraryClip

Clip

Preload input and let the box play it back

Build 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 handle
fn 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 stream
fn 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.

METHODS
MethodAppends
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.

EXAMPLE
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);
MOTION AND AN EDGE ON ONE FRAME

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 playback

From 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.

LOAD & SETTINGS
MethodDoes
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.
TRIGGERS (a managed set)
MethodDoes
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.
ENGINE VERBS
MethodDoes
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-replay

A clip runs in one of two shapes, chosen by set_retain before the first append.

AspectStreaming (default)Retained
Turn onnothing, it's the defaultset_retain(true) before the first append
The ringeach entry is freed as it plays, so it's unboundedentries are kept after playing, up to 64 KiB
Top up mid-playyes, append to the tail in real timeappend until finalize, then it's sealed
Replayno, it plays onceyes, it rewinds and replays
loopnot availableavailable once finalized
stopflushes the bufferrewinds and keeps the clip
Best foropen-ended or generated inputa fixed macro you replay on a trigger
HOW THE RING MOVES
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.

EXAMPLE
// 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 key

A ClipTrigger binds one Edge of a usage to one ClipAction, so the box drives playback itself with no host round-trip.

ANATOMY
PartIsExample
usagethe button, key, or media the edge is on (or any of a class)Key::F1
edgewhich transition fires it: press, release, or bothEdge::Press
actionthe engine verb to runClipAction::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.

RECIPES
To getBind
Hold to playF1 Press → Start and F1 Release → Stop
Toggle play/stop on one keySide1 Press → Toggle
Separate play and stop keysF1 Press → Start and F2 Press → Stop
Pause, then resumeF3 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.

EXAMPLE
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 await

AsyncDevice::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.

EXAMPLE
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