Files
gotta-scroll-fast/daemon/src/main.rs
T
thomas d359a2048f
Tests / test_core_function (push) Failing after 8s
smooth scroll speed estimation to reduce red-dot spikes
2026-03-25 09:38:07 +00:00

373 lines
11 KiB
Rust

use std::{
fs,
path::PathBuf,
time::{Duration, Instant},
};
use anyhow::{Context, anyhow};
use clap::Parser;
use evdev::{
AttributeSet, AttributeSetRef, Device, EventSummary, EventType, InputEvent, RelativeAxisCode,
uinput::VirtualDevice,
};
use gsf_core::{
AccelMode, AllParamArgs, Param,
persist::{ParamStore, SysFsStore},
sensitivity,
};
#[derive(Parser, Debug)]
#[command(author, version, about = "host-side scroll acceleration daemon")]
struct Args {
/// Path to source input device (/dev/input/eventX).
#[arg(long)]
device: Option<PathBuf>,
/// Auto-select device by case-insensitive name substring.
#[arg(long)]
match_name: Option<String>,
/// List candidate devices and exit.
#[arg(long)]
list_devices: bool,
/// Don't create virtual output device, just print transformations.
#[arg(long)]
dry_run: bool,
/// Do not EVIOCGRAB the source device.
#[arg(long)]
no_grab: bool,
}
#[derive(Debug, Default)]
struct AxisRuntime {
last_event_at: Option<Instant>,
remainder: f64,
smoothed_speed: f64,
}
#[derive(Debug, Default)]
struct ScrollState {
vertical: AxisRuntime,
horizontal: AxisRuntime,
}
const SPEED_EMA_ALPHA: f64 = 0.25;
const MIN_DT_SECONDS: f64 = 0.004;
const SPEED_IDLE_RESET_SECONDS: f64 = 0.2;
fn axis_runtime_mut(state: &mut ScrollState, axis: RelativeAxisCode) -> &mut AxisRuntime {
if axis == RelativeAxisCode::REL_HWHEEL || axis == RelativeAxisCode::REL_HWHEEL_HI_RES {
&mut state.horizontal
} else {
&mut state.vertical
}
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
if args.list_devices {
print_devices();
return Ok(());
}
let source_path = if let Some(path) = args.device {
path
} else {
find_default_device(args.match_name.as_deref())
.context("failed to auto-select a scroll-capable device")?
};
let mut source = Device::open(&source_path)
.with_context(|| anyhow!("failed to open input device {}", source_path.display()))?;
let source_name = source.name().unwrap_or("unknown").to_string();
let has_scroll = source
.supported_relative_axes()
.map(has_scroll_axes)
.unwrap_or(false);
if !has_scroll {
anyhow::bail!(
"selected source device does not expose REL_WHEEL/REL_HWHEEL axes: {} ({})",
source_path.display(),
source_name
);
}
if !args.no_grab {
source
.grab()
.with_context(|| anyhow!("failed to grab {}", source_path.display()))?;
}
eprintln!(
"using source device: {} ({})",
source_path.display(),
source_name
);
let mut virtual_dev = if args.dry_run {
None
} else {
Some(build_virtual_from_source(&source).context("failed to create uinput device")?)
};
let store = SysFsStore;
let mut state = ScrollState::default();
let mut last_cfg_reload = Instant::now();
let mut cfg = snapshot_config(&store)?;
loop {
if last_cfg_reload.elapsed() >= Duration::from_millis(100) {
cfg = snapshot_config(&store)?;
last_cfg_reload = Instant::now();
}
let events = source
.fetch_events()
.context("failed to read input events")?
.collect::<Vec<_>>();
let mut out_events = Vec::new();
let suppress_lowres_v = events
.iter()
.any(|ev| ev.code() == RelativeAxisCode::REL_WHEEL_HI_RES.0);
let suppress_lowres_h = events
.iter()
.any(|ev| ev.code() == RelativeAxisCode::REL_HWHEEL_HI_RES.0);
for ev in events {
if let Some(new_ev) = transform_event(
ev,
&cfg,
&mut state,
args.dry_run,
suppress_lowres_v,
suppress_lowres_h,
) {
out_events.push(new_ev);
}
}
if let Some(virtual_dev) = virtual_dev.as_mut()
&& !out_events.is_empty()
{
virtual_dev
.emit(&out_events)
.context("failed to emit event batch to uinput")?;
}
}
}
fn has_scroll_axes(axes: &AttributeSetRef<RelativeAxisCode>) -> bool {
axes.contains(RelativeAxisCode::REL_WHEEL)
|| axes.contains(RelativeAxisCode::REL_WHEEL_HI_RES)
|| axes.contains(RelativeAxisCode::REL_HWHEEL)
|| axes.contains(RelativeAxisCode::REL_HWHEEL_HI_RES)
}
fn print_devices() {
for (path, dev) in evdev::enumerate() {
let has_scroll = dev
.supported_relative_axes()
.map(has_scroll_axes)
.unwrap_or(false);
eprintln!(
"{}\t{}\t{}",
path.display(),
dev.name().unwrap_or("unknown"),
if has_scroll {
"[scroll-capable]"
} else {
"[no-scroll]"
}
);
}
}
fn find_default_device(match_name: Option<&str>) -> anyhow::Result<PathBuf> {
let needle = match_name.map(|s| s.to_ascii_lowercase());
evdev::enumerate()
.find_map(|(path, dev)| {
let has_scroll = dev
.supported_relative_axes()
.map(has_scroll_axes)
.unwrap_or(false);
if !has_scroll {
return None;
}
if let Some(needle) = needle.as_ref() {
let name = dev.name().unwrap_or("unknown").to_ascii_lowercase();
if !name.contains(needle) {
return None;
}
}
Some(path)
})
.ok_or_else(|| anyhow!("no matching scroll-capable device found"))
}
fn build_virtual_from_source(source: &Device) -> anyhow::Result<VirtualDevice> {
let name = format!("gsf virtual: {}", source.name().unwrap_or("unknown"));
let mut rel_axes = AttributeSet::<RelativeAxisCode>::new();
if let Some(axes) = source.supported_relative_axes() {
for axis in axes.iter() {
rel_axes.insert(axis);
}
}
let mut builder = VirtualDevice::builder()?.name(name.as_bytes());
if let Some(keys) = source.supported_keys() {
builder = builder.with_keys(keys)?;
}
builder = builder.with_relative_axes(&rel_axes)?;
let mut device = builder.build()?;
for path in device.enumerate_dev_nodes_blocking()? {
let path = path?;
eprintln!("virtual device node: {}", path.display());
}
Ok(device)
}
fn snapshot_config(store: &SysFsStore) -> anyhow::Result<(AccelMode, AllParamArgs)> {
let mode = store.get_current_accel_mode()?;
let params = AllParamArgs {
sens_mult: store.get(Param::SensMult)?,
yx_ratio: store.get(Param::YxRatio)?,
input_dpi: store.get(Param::InputDpi)?,
accel: store.get(Param::Accel)?,
offset_linear: store.get(Param::OffsetLinear)?,
offset_natural: store.get(Param::OffsetNatural)?,
output_cap: store.get(Param::OutputCap)?,
decay_rate: store.get(Param::DecayRate)?,
limit: store.get(Param::Limit)?,
gamma: store.get(Param::Gamma)?,
smooth: store.get(Param::Smooth)?,
motivity: store.get(Param::Motivity)?,
sync_speed: store.get(Param::SyncSpeed)?,
angle_rotation: store.get(Param::AngleRotation)?,
};
Ok((mode, params))
}
fn transform_event(
ev: InputEvent,
cfg: &(AccelMode, AllParamArgs),
state: &mut ScrollState,
dry_run: bool,
suppress_lowres_v: bool,
suppress_lowres_h: bool,
) -> Option<InputEvent> {
let (mode, params) = cfg;
match ev.destructure() {
EventSummary::RelativeAxis(_, axis, value)
if axis == RelativeAxisCode::REL_WHEEL
|| axis == RelativeAxisCode::REL_WHEEL_HI_RES
|| axis == RelativeAxisCode::REL_HWHEEL
|| axis == RelativeAxisCode::REL_HWHEEL_HI_RES =>
{
if (axis == RelativeAxisCode::REL_WHEEL && suppress_lowres_v)
|| (axis == RelativeAxisCode::REL_HWHEEL && suppress_lowres_h)
{
return None;
}
let axis_state = axis_runtime_mut(state, axis);
let now = Instant::now();
let dt = axis_state
.last_event_at
.map(|t| now.saturating_duration_since(t).as_secs_f64())
.unwrap_or(0.016)
.max(MIN_DT_SECONDS);
axis_state.last_event_at = Some(now);
let axis_unit = if axis == RelativeAxisCode::REL_WHEEL_HI_RES
|| axis == RelativeAxisCode::REL_HWHEEL_HI_RES
{
120.0
} else {
1.0
};
// Normalize hi-res wheel units (120 per detent) to "detent-equivalent"
// speed so tuning behaves similarly across REL_WHEEL and REL_WHEEL_HI_RES.
let normalized_value = (value as f64) / axis_unit;
let raw_speed = normalized_value.abs() / dt;
// Smooth out bursty evdev timing so the live indicator and accel input
// don't jump wildly on single tightly-packed events.
let speed = if dt > SPEED_IDLE_RESET_SECONDS {
raw_speed
} else {
SPEED_EMA_ALPHA * raw_speed + (1.0 - SPEED_EMA_ALPHA) * axis_state.smoothed_speed
};
axis_state.smoothed_speed = speed;
let (gain_x, gain_y) = sensitivity(speed, *mode, params);
let gain = if axis == RelativeAxisCode::REL_HWHEEL
|| axis == RelativeAxisCode::REL_HWHEEL_HI_RES
{
gain_x
} else {
gain_y
};
let scaled = (value as f64) * gain + axis_state.remainder;
let quantized = scaled.trunc() as i32;
axis_state.remainder = scaled - (quantized as f64);
let _ = write_speed_stat(speed);
if dry_run {
eprintln!(
"scroll {:?}: in={} speed={:.2} gain={:.3} out={}",
axis, value, speed, gain, quantized
);
}
if quantized == 0 {
return None;
}
Some(InputEvent::new(EventType::RELATIVE.0, ev.code(), quantized))
}
_ => Some(ev),
}
}
fn write_speed_stat(speed: f64) -> anyhow::Result<()> {
let path = stats_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| anyhow!("failed to create {}", parent.display()))?;
}
fs::write(&path, format!("{speed:.6}"))
.with_context(|| anyhow!("failed to write {}", path.display()))?;
Ok(())
}
fn stats_path() -> PathBuf {
if let Ok(path) = std::env::var("GSF_STATS_PATH") {
return PathBuf::from(path);
}
// Keep default shared across sudo/non-sudo runs.
PathBuf::from("/tmp/gsf-speed.txt")
}