Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Input

Every input method on Element comes in two variants:

  • Realistic (default) — Bezier-interpolated cursor moves for the mouse, per-character delays with occasional typos for the keyboard. Tuned to defeat behavioral fingerprinters.
  • _fast — single CDP dispatch, no delays, no jitter, no typos. Skips the actionability gate. For tests and fast automation flows where deterministic timing matters more than realism.

Both flavors route through the same shared InputController on each tab, so the OS-level modifier state (Shift, Ctrl, etc.) stays consistent across realistic and fast paths.

Realistic vs _fast

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let btn = tab.find().css("button").one().await?;

// Realistic: Bezier-path cursor approach, hover, then mousedown/up.
btn.click().await?;

// Fast: single Input.dispatchMouseEvent, no actionability gate.
btn.click_fast().await?;
Ok(()) }
}
MethodCursor pathGateUse case
click()BezieractionabilityDefault. Indistinguishable.
click_fast()teleportskippedTests; trusted automation.
hover()BezieractionabilityDefault. Real cursor approach.
hover_fast()teleportskippedTests; trusted automation.
type_text(s)per-char + delaysfocus gateDefault. Sub-keystroke timing.
type_text_fast(s)per-char, no delayfocus gateTests; trusted automation.

By default, the realism comes from the active StealthProfile's InputProfile:

  • StealthProfile::spoofed() installs a realistic profile by default — Bezier control points with deterministic-but-jittered timing, per-character keyboard delays of 30-200 ms, occasional 1-2% typo + correction events.
  • StealthProfile::native() and ::off() install a zero-overhead, deterministic profile by default — even realistic methods just do the dispatch without added realism.

When realism matters but you also want determinism (e.g. snapshots inside tests), seed the profile with a fixed RNG — see the InputProfile rustdoc.

Opt-in: decoupling input timing from stealth

BrowserBuilder::input_profile() lets you pick the InputProfile explicitly, independent of StealthProfile. This is opt-in only — it does not change any default. With no .input_profile(..) call, timing still resolves to whatever the active StealthProfile implies (the same mapping described above — InputProfile::spoofed() under StealthProfile::spoofed(), InputProfile::native() under ::native() or ::off()), exactly as before this method existed.

Use it when you want to decouple timing from the stealth setting — e.g. humanized timing without also turning on stealth's surface patches (canvas/WebGL/navigator overrides), or stealth on but deterministic zero-delay input for a test:

#![allow(unused)]
fn main() {
use zendriver::stealth::{InputProfile, StealthProfile};

async fn ex() -> zendriver::Result<()> {
// Stealth off (stock Chrome launch), but keep human-paced typing and
// jittery mouse motion — previously impossible, since input timing was
// derived from the stealth profile.
let browser = zendriver::Browser::builder()
    .stealth(StealthProfile::off())
    .input_profile(InputProfile::coherent())
    .launch()
    .await?;
browser.close().await?;
Ok(()) }
}

BrowserBuilder::resolved_input_profile() returns the effective profile before launch, for tests/inspection — same pattern as resolved_persona().

ClickOptions for fine control

Both click() and click_fast() are wrappers around Element::click_with(), which takes a ClickOptions struct for full control:

#![allow(unused)]
fn main() {
use zendriver::{ClickOptions, MouseButton, KeyModifiers};

async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let row = tab.find().css("tr.contact").one().await?;

// Right-click.
row.click_with(ClickOptions {
    button: MouseButton::Right,
    ..Default::default()
}).await?;

// Ctrl+click (open in new tab).
let link = tab.find().css("a.external").one().await?;
link.click_with(ClickOptions {
    modifiers: KeyModifiers::CTRL,
    ..Default::default()
}).await?;

// Double-click.
let item = tab.find().css(".item").one().await?;
item.click_with(ClickOptions {
    click_count: 2,
    ..Default::default()
}).await?;

// Click at a specific offset inside the element's bbox.
let canvas = tab.find().css("canvas").one().await?;
canvas.click_with(ClickOptions {
    position: Some((100.0, 50.0)),
    ..Default::default()
}).await?;
Ok(()) }
}

The full ClickOptions shape:

FieldTypeDefaultMeaning
buttonMouseButtonMouseButton::LeftWhich button to dispatch.
modifiersKeyModifiersKeyModifiers::empty()Modifier bits held during dispatch.
click_countu321clickCount for the dispatch (2 = double-click).
forceboolfalseSkip the actionability gate. Mirrors Playwright.
realisticbooltrueBezier path vs teleport.
positionOption<(f64, f64)>None (bbox center)Click offset relative to bbox top-left.

Touch / tap

Element::tap() taps an element's bbox center via a real touch dispatch (Input.dispatchTouchEvent touchStarttouchEnd), not a mouse click. It mirrors click()'s scroll-into-view + actionability-gate + bbox-center path, but ends in a touch event pair instead of mousePressed/mouseReleased — for pages that branch their handling on touch vs mouse input (an ontouchstart listener, or pointerType checks).

Tab::tap(x, y) is the coordinate-level equivalent, for canvas / custom widgets with no element to target — mirrors Tab::mouse_click().

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let btn = tab.find().css("button").one().await?;
btn.tap().await?;

// Or at a raw coordinate:
tab.tap(120.0, 240.0).await?;
Ok(()) }
}

touchEnd carries an empty touchPoints array — that's the CDP contract for a lifted finger, not a bug.

Capability-emulation caveat: tap() does not call Emulation.setTouchEmulationEnabled, so it doesn't flip touch-capability signals a page might probe — 'ontouchstart' in window, navigator.maxTouchPoints, matchMedia('(pointer: coarse)'). The bare dispatchTouchEvent still fires the page's real touchstart/touchend handlers (and, on a clickable element, the browser's own synthesized click), which is what a tap needs; a page that gates its behavior on those capability signals before wiring up touch listeners won't see them flip. Full capability emulation belongs with mobile device emulation (viewport + UA + touch capability together) — a later, larger feature, not part of this tap primitive.

Scope is touch only: no pressure, pen/stylus, or tilt input yet.

Keyboard: Key, KeyModifiers, SpecialKey

For single-key dispatches (Enter, Tab, arrow keys, Ctrl+A, etc.):

#![allow(unused)]
fn main() {
use zendriver::{Key, KeyModifiers, SpecialKey};

async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let input = tab.find().css("input").one().await?;

// Press Enter (named special key).
input.press(Key::Special(SpecialKey::Enter)).await?;

// Press Tab to move focus.
input.press(Key::Special(SpecialKey::Tab)).await?;

// Ctrl+A (select all).
input.press_with(Key::Char('a'), KeyModifiers::CTRL).await?;

// Ctrl+Shift+End.
input.press_with(
    Key::Special(SpecialKey::End),
    KeyModifiers::CTRL | KeyModifiers::SHIFT,
).await?;
Ok(()) }
}

Key is one of:

  • Key::Char(char) — any typeable character.
  • Key::Special(SpecialKey) — a key with a name rather than a glyph.

SpecialKey covers Enter, Tab, Escape, Backspace, Delete, Space, all four arrows, Home, End, PageUp, PageDown, F1-F12, Insert, CapsLock, NumLock, ScrollLock, PrintScreen, Pause, ContextMenu — the whole named keyboard. Two of them still type: Space inserts a space, and Enter inserts a newline in a <textarea> or contenteditable while in a single-line <input> it inserts nothing and only submits a form that allows implicit submission — exactly as the physical keys do. The rest only run their default action. Holding Ctrl, Alt or Meta suppresses the insertion, for those two and for character keys alike, the same way a shortcut does on real hardware.

KeyModifiers is a bitflags struct:

  • KeyModifiers::ALT — Alt (Option on macOS).
  • KeyModifiers::CTRL — Control.
  • KeyModifiers::META — Meta (Command on macOS, Windows key on Windows).
  • KeyModifiers::SHIFT — Shift.

Combine with |: KeyModifiers::CTRL | KeyModifiers::SHIFT.

press vs press_with

  • Element::press(key) uses whatever modifiers are currently held by the InputController — useful when you've explicitly tracked modifier-held state (e.g. via held-key sequences).
  • Element::press_with(key, mods) passes mods straight through to the CDP dispatch for this one call, without mutating the controller's tracked state — the safer default when you want a single key event with specific modifiers.

End-to-end form fill

//! Fill out a small HTML form rendered via `data:` URL — demonstrates the
//! P3 input surface end-to-end without depending on any third-party site.
//!
//! Equivalent in spirit to the form-fill snippets scattered through the
//! Python `examples/` directory (`network_monitor.py`'s search-and-submit
//! flow, `imgur_upload_image.py`'s title-field fill). Picks `data:` over
//! a third-party form so the example stays deterministic across runs.
//!
//! Sequence:
//!   1. CSS-select the inputs and submit button.
//!   2. [`Element::type_text`] simulates per-character key events with the
//!      Bezier/jitter realism from the [`StealthProfile`]'s `InputProfile`.
//!   3. [`Element::click`] dispatches a real `mousedown` + `mouseup` via
//!      `Input.dispatchMouseEvent` after running the actionability gates.
//!   4. Read back the form's serialized state via `evaluate_main` to prove
//!      the inputs took our values.

use zendriver::Browser;

const FORM_HTML: &str = "data:text/html,\
<!doctype html><html><body>\
<form id='f' onsubmit='window.submitted=true;return false'>\
<input id='user' name='user' />\
<input id='pass' name='pass' type='password' />\
<button id='go' type='submit'>Submit</button>\
</form></body></html>";

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto(FORM_HTML).await?;
    tab.wait_for_load().await?;

    let user = tab.find().css("#user").one().await?;
    user.type_text("rin").await?;

    let pass = tab.find().css("#pass").one().await?;
    pass.type_text("hunter2").await?;

    let go = tab.find().css("#go").one().await?;
    go.click().await?;

    let user_val: String = tab
        .evaluate_main("document.getElementById('user').value")
        .await?;
    let submitted: bool = tab.evaluate_main("window.submitted === true").await?;
    println!("user field = {user_val:?}, submitted = {submitted}");

    browser.close().await?;
    Ok(())
}

Expected output:

user field = "rin", submitted = true

The example demonstrates the realistic input surface end-to-end: per-character typing into two inputs, then a single click on the submit button. All three calls (type_text + type_text + click) go through the actionability gate and the realistic-cursor path.

When to use _fast variants

  • Tests where you only care that the action happened, not how it looked to a fingerprinter.
  • Trusted automation pipelines (internal admin tools, scraping flows where you already know stealth isn't being checked).
  • CI where every saved millisecond per click compounds across thousands of runs.
  • Setup steps (typing a known query into a search box before the real interaction starts). Save realism for the moments that matter.

When in doubt, stick with the realistic defaults — the per-call cost is small (typically 50-300 ms per click; a few ms per typed character) and it keeps input timing on the human-plausible path that the rest of the stealth machinery is built around.