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, /// Auto-select device by case-insensitive name substring. #[arg(long)] match_name: Option, /// 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, 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::>(); 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) -> 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 { 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 { let name = format!("gsf virtual: {}", source.name().unwrap_or("unknown")); let mut rel_axes = AttributeSet::::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 { 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") }