Clip
A ClipBuilder builds per-frame input. The ClipHandle from Device::clip() loads it. The box drains it into the same injection state that move and inject feed.
Playback is box-clocked, so it carries no host scheduling jitter. One clip covers mouse, keyboard, media, raw reports, and control transfers. 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 native key edge or a matched packet, no host round-trip:
handle.bind(ClipTrigger::new(Key::F1, Edge::Press, ClipAction::Start))
handle.bind_packet(&ClipPacketTrigger::new(
TrafficClass::HidIn, 2, Direction::IN, ClipAction::Start))clipOpen a clip handle
fn clip(&self) -> ClipHandle
No round-trip
Returns a ClipHandle bound to this box. Keep one per clip session: it owns the append-sequence counter the box uses to spot a dropped append.
A clip plays on any clone. The keepalive holds a loaded clip, its settings and both kinds of trigger past the silence window. If the link is down for longer, the box clears them, and the clip and config need reloading. A device-chip restart or a released session empties the ring. The library then re-sends the settings and triggers, and lost flags the clip for reload.
allow_imperfect_clones(false) causes a partial release. It drops consuming packet triggers and queued transfer items. Unless the toggle also re-presents the clone, it keeps the ring, its settings and every other trigger.
The ring is 64 KiB with PSRAM and 16 KiB without. ClipStatus::free on a cleared ring is the size.
ClipBuilder Build the entry stream
fn new() -> ClipBuilder
No round-trip
Each method appends one per-frame entry. Motion is a relative delta. An edge is an Action that stays held until a later frame changes it. A gap NAKs like an idle mouse.
| 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. |
pan(dpan) | a pan (horizontal scroll) 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. |
raw(ep, direction, bytes) | a frame carrying one raw report. |
transfer(ep, setup, out) | a frame carrying one control transfer. |
frame(frame) | one frame carrying whatever a ClipFrame holds. |
They take &mut self and return &mut Self, for chaining or a loop. clear() reuses the allocation, and byte_len() is the ring space the entries take. Each one-field call wraps frame.
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 fills more than one field, so a move and an edge share one entry and one emitted report.
use medius::{Button, ClipBuilder, ClipFrame};
let mut clip = ClipBuilder::new();
// move (+10, -4) AND press Left on the same frame
clip.frame(ClipFrame::new().move_by(10, -4).press(Button::LEFT));
// press once, keep moving while held, then release
clip.frame(ClipFrame::new().move_by(8, -2).press(Button::LEFT));
for _ in 0..60 { clip.move_by(8, -2); } // Left stays down (edges are sticky)
clip.release(Button::LEFT);ClipFrame Build one multi-field frame
fn new() -> ClipFrame
No round-trip
One frame carries any mix of motion, edges, raw reports, and control transfers, ≤512 encoded bytes (CLIP_ENTRY_MAX). Each method takes self and returns the frame.
| Method | Does |
|---|---|
move_by(dx, dy) | Set the cursor motion, a relative delta. |
wheel(dz) / pan(dpan) | Set the wheel or the pan (horizontal scroll) motion. |
press / release / force_release(usage) | Add a press, soft-release, or force-release edge of any Usage. |
edge(usage, action) | Add an edge with an explicit Action. A frame holds ≤8 (CLIP_EDGES_MAX). |
raw(ep, direction, bytes) | Add a raw report, as Device::raw sends one. A frame holds ≤8 (CLIP_RAW_MAX), sent in order ahead of the frame's report. |
transfer(ep, setup, out) | Add a control transfer, as Device::transfer runs one: out is setup.length bytes for an OUT request, empty for an IN one. Each reply is a TrafficClass::ClipTransfer catch event. |
byte_len() | The ring bytes the frame takes: ≤512 for a frame append accepts. |
Raw reports and transfers need allow_imperfect_clones on as the frame plays. With it off, the box discards them and counts each in ClipStatus::gated.
use medius::{Button, ClipBuilder, ClipFrame, Direction, Setup};
let mut clip = ClipBuilder::new();
clip.frame(
ClipFrame::new()
.move_by(4, -2)
.press(Button::LEFT)
.raw(2, Direction::OUT, [0x10, 0xFF, 0x05])
.transfer(0, Setup::new(0x21, 0x09, 0x0300, 0, 2), [0x04, 0x01]),
);
device.clip().append(&clip)?;ClipHandle Fill the ring, configure, and drive playback
From Device::clip(). Every method below is fire-and-forget. query_status reads ring depth and playback state. query_config reads the config back.
| Method | Does |
|---|---|
append(clip: &ClipBuilder) | Send a ClipBuilder's entries to the ring. A large clip is split into whole-entry frames with contiguous append seqs. Every entry is checked first, so a refusal sends nothing. |
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. |
set_ride(on: bool) | Make the clip's motion wait for native motion under movement riding (false = the box's clock, the default). Changeable mid-playback. With rendering on and a profile armed, it applies to wheel and pan only. |
finalize() | Close a retained clip: fix its end so it can replay and loop. |
| Method | Does |
|---|---|
bind(trigger: ClipTrigger) | Add or overwrite an input trigger: a native edge drives an action on the box, no host round-trip. |
unbind(usage, edge: Edge) | Remove the input trigger on that usage and edge. |
bind_packet(trigger: &ClipPacketTrigger) | Add or overwrite a packet trigger: a matched packet drives an action on the frame clock's next tick. |
unbind_packet(trigger: &ClipPacketTrigger) | Remove the packet trigger with that trigger's key, ignoring its action, consume, once_per_run and selector_len. A key the box can't hold is refused, as bind_packet refuses it. |
clear_triggers() | Remove every trigger of both kinds. |
| Method | Does |
|---|---|
start() | Rewind to the clip start and play (resume from a pause). |
stop() | Stop, and 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 faults the clip (ClipState::Faulted) and stops it. A faulted stream is missing entries: clear and rebuild.
lostWhether the box dropped the loaded clip
fn lost(&self) -> bool
No round-trip
| Value | When |
|---|---|
true | A clip appended since the last clear is gone from the box: its device chip restarted, the box released the session, or a reconnect found the ring empty. Goes true once the box can take a reload. |
false | Nothing appended, the clip still on the box, or an append or clear since. |
A recovery restores the settings and triggers, not the ring's content. The caller rebuilds and appends the clip.
let handle = device.clip();
handle.append(&clip)?;
// later, from a loop that owns the clip
if handle.lost() {
handle.append(&clip)?; // reload; lost() reads false again
}Streaming and retained Drain-and-discard, or keep-and-replay
set_retain picks the mode before the first append.
| Aspect | Streaming (default) | Retained |
|---|---|---|
| Turn on | default | set_retain(true) before the first append |
| Ring | each entry freed as it plays; unbounded | entries kept after playing, up to the ring size |
| Top up mid-play | yes, appends to the tail in real time | until finalize, then sealed |
| Replay | no, plays once | yes, 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 replayed 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 the ring size)
append --> [ e0 e1 e2 e3 e4 ] --> finalize --> sealed
base |--> play cursor --> end
+---- start / loop rewinds to base <----+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 X and Y while playing
handle.append(&clip)?; // preload (clip, next_chunk: built ClipBuilders)
handle.start()?;
loop {
let s = handle.query_status()?;
if s.state == ClipState::Idle { break; } // done, or stopped
if s.free as usize > next_chunk.byte_len() {
handle.append(&next_chunk)?; // stream more while there's room
}
std::thread::sleep(Duration::from_millis(5));
}
handle.stop()?;TriggersRun a clip action on a native edge or a matched packet
A trigger runs one ClipAction on the box, with no host round-trip. One set holds both kinds.
a native edge a packet at a traffic surface
button, key, media HidIn, HidOut, VendorInterrupt,
| VendorBulk, Control, Emit
v |
[ input triggers ] 8 v
bind, unbind [ packet triggers ] 8
| bind_packet, unbind_packet
| action | action | packet
v v v
+-----> ClipAction <------------+ [ rewrite table ]
run on the box reads it next, unless
the trigger consumed it
|
v
delivered| Aspect | Input trigger | Packet trigger |
|---|---|---|
| Type | ClipTrigger | ClipPacketTrigger |
| Fires on | a press or release edge of a button, key, or media usage | a packet on a traffic surface, by a masked head compare |
| Key | usage and edge | class, id, direction, match and mask |
| Box capacity | 8 | 8, with 112 match bytes between them |
.consume() | locks the usage for the hold | drops every packet the trigger matches as the top-ranked trigger. Needs the imperfect-clone opt-in. |
| Calls | bind, unbind | bind_packet, unbind_packet |
| Reads back in | triggers | packet_triggers, each with its hits |
The keepalive holds bound triggers past the silence window, and a reconnect reads the set back. A drop shorter than the window leaves every trigger bound; a longer one clears the set.
| Part | Meaning | 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 |
Input triggers are keyed by (usage, edge), like a lock. A native edge runs only the most specific match: Key::F1 before an any-key trigger.
| Goal | 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 |
On a press binding, .consume() locks the trigger usage while the trigger stays active. On an Edge::Release binding it has no effect.
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. The press binding's consume locks F1
// for the whole hold; the release binding needs none.
clip.bind(ClipTrigger::new(Key::F1, Edge::Press, ClipAction::Start).consume())?;
clip.bind(ClipTrigger::new(Key::F1, Edge::Release, ClipAction::Stop))?;
// Or one side-button that toggles play/stop:
clip.bind(ClipTrigger::new(Button::SIDE1, Edge::Press, ClipAction::Toggle))?;A packet trigger is clip config. It shares the clip's lifetime (held by the keepalive, cleared with the clip on a hard stop) and its read-back. It sits beside the rewrite table and reads each packet as it arrived, independent of the table. One packet can both fire a trigger and have a rule act on it.
catch_events shows the address and bytes to match: a HidIn event's id is the interface number and its bytes are the report. The box's rules are on CLIP_TRIGGER.
| Part | Meaning |
|---|---|
| address | the TrafficClass, the id within it (the interface number for HidIn, the endpoint number for the rest, or ANY_ID), and the Direction |
| action | the engine verb to run, on the frame clock's next tick: within one frame of the matching packet, 1 ms at the default pace |
.matching() | head bytes and their mask, of one length, ≤16 bytes. Without it, every packet on the address matches. |
.consume() | drop every packet the trigger matches as the top-ranked trigger, whether or not the action runs on it. A consumed HidIn report is dropped whole, motion and buttons included. A release edge in it is emitted with the next report. |
.once_per_run() | run the action on the first packet of a run of matching ones. The first selector_len match bytes select the stream (a report ID, say), and the rest are the condition. |
| Trigger | Needs the opt-in | Reason |
|---|---|---|
| a trigger that watches | no | It alters no byte the PC or the real device receives. |
| a trigger that consumes | yes | It drops traffic, as a Drop rewrite rule does. |
| Goal | Bind |
|---|---|
| Hold a vendor button to play | two once_per_run triggers: the pressed bytes to Start, the released bytes to Stop |
| Keep that button from the PC | .consume() on the pressed trigger, under allow_imperfect_clones(true) |
| Restart when the PC writes a report | HidOut or Control, Direction::OUT, to Restart |
A run starts on the first matching packet the trigger reads. If a trigger's condition already holds when it is bound, it fires on the next packet of its stream. So on a real device that reports every poll, a release trigger bound with the button up fires once. Bind the press trigger first and the release trigger while the button is held, or accept the one action.
Of the triggers a packet matches, only the most specific acts on it: an exact id over ANY_ID, more masked bits over fewer, IN or OUT over Both, then the one bound earlier. hits counts only packets matched as the top-ranked trigger. An outranked trigger's hits stays still, though it keeps tracking its run.
| Report | Top-ranked trigger | Result |
|---|---|---|
07 20 .. | [07 20]/[FF 20], 9 masked bits | its action runs and the report is relayed |
07 00 .. | a consuming [07]/[FF], 8 masked bits | its action runs and the report is dropped |
08 20 .. | neither | the report is relayed |
bind_packet returns Error::ClipPacketTrigger before anything is sent, and reason says which check failed. A With or Against direction returns Error::RelativeDirection.
| Trigger | Reason |
|---|---|
Bus or ClipTransfer as the class | Neither is a surface packets cross. |
a match and a mask of different lengths, or >16 bytes (PKT_MATCH_MAX) | The box compares them byte for byte over the packet head. |
| a match bit outside its mask | No packet can match it. |
Direction::OUT on HidIn or Emit, Direction::IN on HidOut | The class never carries that flow. |
.consume() on Control | A control transfer always runs to completion. |
.once_per_run() on Control, with ANY_ID, or with Direction::Both | A run is over one stream. |
.once_per_run() with a selector ≥ the match length, or condition bytes with no masked bit | The bytes past the selector are the condition. A condition every packet meets never ends its run. |
The box makes three checks that bind_packet can't: a consuming trigger needs allow_imperfect_clones(true), the set holds 8 triggers (CLIP_PKT_TRIG_MAX), and their match bytes share a pool of 112 (CLIP_PKT_MATCH_POOL). A bind the box refuses leaves the set as it was: a new key isn't held, and a held key keeps its old trigger. Compare what query_config reads back with what was bound. Turning the opt-in off removes the consuming triggers.
use medius::{ClipAction, ClipPacketTrigger, Direction, TrafficClass};
device.allow_imperfect_clones(true)?; // first: a consuming trigger needs it
let clip = device.clip();
// Interface 2 is a vendor-page HID interface. Report ID 7 carries a button in bit 5 of its
// second byte, repeated every poll while held, so the report ID is the selector.
let held = ClipPacketTrigger::new(TrafficClass::HidIn, 2, Direction::IN, ClipAction::Start)
.matching([0x07, 0x20], [0xFF, 0x20])
.once_per_run(1)
.consume(); // the press never reaches the PC: needs the opt-in
let let_go = ClipPacketTrigger::new(TrafficClass::HidIn, 2, Direction::IN, ClipAction::Stop)
.matching([0x07, 0x00], [0xFF, 0x20])
.once_per_run(1);
clip.bind_packet(&held)?;
clip.bind_packet(&let_go)?; // bound with the button up: one Stop on the next report
// A SET_REPORT on EP0 restarts the clip. A trigger that only watches needs no opt-in.
let set_report = ClipPacketTrigger::new(TrafficClass::Control, 0, Direction::OUT, ClipAction::Restart)
.matching([0x21, 0x09], [0xFF, 0xFF]);
clip.bind_packet(&set_report)?;
// A refused bind leaves the set unchanged: compare the read-back with what was bound.
let bound = clip.query_config()?.packet_triggers;
for t in [&held, &let_go, &set_report] {
match bound.iter().find(|e| &e.trigger == t) {
Some(e) => println!("{:?} id {} -> {:?}, {} hits", t.class, t.id, t.action, e.hits),
None => println!("the box did not take {t:?}"),
}
}
clip.unbind_packet(&let_go)?; // by key: action, consume, once_per_run and selector_len are ignored
clip.clear_triggers()?; // both kindsOn AsyncDevice AsyncClipHandle: control fires, queries await
AsyncDevice::clip() returns an AsyncClipHandle that keeps append, the settings, both kinds of trigger, the engine verbs and lost synchronous. query_status().await and query_config().await are futures.
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