From 6ef326188a8026918171bb482fe89147e33078b5 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Tue, 24 Mar 2026 12:10:31 +0000 Subject: [PATCH] add host-side daemon and config-backed parameter store --- crates/core/src/inputspeed.rs | 54 +++++ crates/core/src/persist.rs | 250 ++++++++++++++++++++ daemon/Cargo.toml | 14 ++ daemon/src/main.rs | 351 ++++++++++++++++++++++++++++ packaging/systemd/user/gsfd.service | 16 ++ 5 files changed, 685 insertions(+) create mode 100644 crates/core/src/inputspeed.rs create mode 100644 crates/core/src/persist.rs create mode 100644 daemon/Cargo.toml create mode 100644 daemon/src/main.rs create mode 100644 packaging/systemd/user/gsfd.service diff --git a/crates/core/src/inputspeed.rs b/crates/core/src/inputspeed.rs new file mode 100644 index 0000000..ce62b65 --- /dev/null +++ b/crates/core/src/inputspeed.rs @@ -0,0 +1,54 @@ +//! Provides functions to get the latest observed scroll speed. +//! For host-side mode, the daemon writes a single numeric value to a stats file. + +use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::Context; + +static INPUT_SPEED_BITS: AtomicU64 = AtomicU64::new(0); + +pub fn read_input_speed() -> f64 { + f64::from_bits(INPUT_SPEED_BITS.load(Ordering::Relaxed)) +} + +pub fn setup_input_speed_reader() -> JoinHandle> { + thread::spawn(|| { + let path = stats_path(); + + loop { + if let Ok(content) = std::fs::read_to_string(&path) + && let Ok(parsed) = content.trim().parse::() + { + INPUT_SPEED_BITS.store(parsed.to_bits(), Ordering::Relaxed); + } + + thread::sleep(Duration::from_millis(75)); + } + }) +} + +fn stats_path() -> PathBuf { + if let Ok(path) = std::env::var("GSF_STATS_PATH") { + return PathBuf::from(path); + } + + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime_dir).join("gsf-speed.txt"); + } + + let home = std::env::var("HOME").context("HOME missing").ok(); + if let Some(home) = home { + return PathBuf::from(home) + .join(".local") + .join("state") + .join("gsf") + .join("speed.txt"); + } + + PathBuf::from("/tmp/gsf-speed.txt") +} diff --git a/crates/core/src/persist.rs b/crates/core/src/persist.rs new file mode 100644 index 0000000..59e9b1d --- /dev/null +++ b/crates/core/src/persist.rs @@ -0,0 +1,250 @@ +use std::{ + fmt::{Debug, Display}, + path::PathBuf, +}; + +use anyhow::{Context, anyhow}; +use serde::{Deserialize, Serialize}; + +use crate::{ + fixedptc::Fpt, + params::{ + ALL_MODES, AccelMode, CommonParamArgs, LinearParamArgs, NaturalParamArgs, Param, + SynchronousParamArgs, format_param_value, validate_param_value, + }, +}; + +pub trait ParamStore: Debug { + fn set(&mut self, param: Param, value: f64) -> anyhow::Result<()>; + fn get(&self, param: Param) -> anyhow::Result; + + fn set_current_accel_mode(&mut self, mode: AccelMode) -> anyhow::Result<()>; + fn get_current_accel_mode(&self) -> anyhow::Result; +} + +const CONFIG_ENV_VAR: &str = "GSF_CONFIG_PATH"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredConfig { + mode: u8, + sens_mult: f64, + yx_ratio: f64, + input_dpi: f64, + angle_rotation: f64, + accel: f64, + offset_linear: f64, + output_cap: f64, + decay_rate: f64, + offset_natural: f64, + limit: f64, + gamma: f64, + smooth: f64, + motivity: f64, + sync_speed: f64, +} + +impl Default for StoredConfig { + fn default() -> Self { + Self { + mode: AccelMode::Linear as u8, + sens_mult: 1.0, + yx_ratio: 1.0, + input_dpi: 1000.0, + angle_rotation: 0.0, + accel: 0.0, + offset_linear: 0.0, + output_cap: 0.0, + decay_rate: 0.1, + offset_natural: 0.0, + limit: 1.5, + gamma: 1.0, + smooth: 0.5, + motivity: 1.5, + sync_speed: 5.0, + } + } +} + +#[derive(Debug)] +pub struct SysFsStore; + +impl ParamStore for SysFsStore { + fn set(&mut self, param: Param, value: f64) -> anyhow::Result<()> { + validate_param_value(param, value)?; + + let mut cfg = load_or_init_config()?; + set_param_on_config(&mut cfg, param, value); + save_config(&cfg)?; + Ok(()) + } + + fn get(&self, param: Param) -> anyhow::Result { + let cfg = load_or_init_config()?; + Ok(get_param_from_config(&cfg, param).into()) + } + + fn set_current_accel_mode(&mut self, mode: AccelMode) -> anyhow::Result<()> { + let mut cfg = load_or_init_config()?; + cfg.mode = mode as u8; + save_config(&cfg)?; + Ok(()) + } + + fn get_current_accel_mode(&self) -> anyhow::Result { + let cfg = load_or_init_config()?; + let idx = (cfg.mode as usize) % ALL_MODES.len(); + Ok(ALL_MODES[idx]) + } +} + +impl SysFsStore { + pub fn set_all_common(&mut self, args: CommonParamArgs) -> anyhow::Result<()> { + let CommonParamArgs { + sens_mult, + yx_ratio, + input_dpi, + angle_rotation, + } = args; + + self.set(Param::SensMult, sens_mult)?; + self.set(Param::YxRatio, yx_ratio)?; + self.set(Param::InputDpi, input_dpi)?; + self.set(Param::AngleRotation, angle_rotation)?; + + Ok(()) + } + + pub fn set_all_linear(&mut self, args: LinearParamArgs) -> anyhow::Result<()> { + let LinearParamArgs { + accel, + offset_linear, + output_cap, + } = args; + + self.set(Param::Accel, accel)?; + self.set(Param::OffsetLinear, offset_linear)?; + self.set(Param::OutputCap, output_cap)?; + + Ok(()) + } + + pub fn set_all_natural(&mut self, args: NaturalParamArgs) -> anyhow::Result<()> { + let NaturalParamArgs { + decay_rate, + limit, + offset_natural, + } = args; + + self.set(Param::DecayRate, decay_rate)?; + self.set(Param::OffsetNatural, offset_natural)?; + self.set(Param::Limit, limit)?; + + Ok(()) + } + + pub fn set_all_synchronous(&mut self, args: SynchronousParamArgs) -> anyhow::Result<()> { + let SynchronousParamArgs { + gamma, + smooth, + motivity, + sync_speed, + } = args; + + self.set(Param::Gamma, gamma)?; + self.set(Param::Smooth, smooth)?; + self.set(Param::Motivity, motivity)?; + self.set(Param::SyncSpeed, sync_speed)?; + + Ok(()) + } +} + +fn config_path() -> anyhow::Result { + if let Ok(p) = std::env::var(CONFIG_ENV_VAR) { + return Ok(PathBuf::from(p)); + } + + let home = std::env::var("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home) + .join(".config") + .join("gsf") + .join("config.json")) +} + +fn load_or_init_config() -> anyhow::Result { + let path = config_path()?; + + if !path.exists() { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| anyhow!("failed to create config directory {}", parent.display()))?; + } + let cfg = StoredConfig::default(); + save_config(&cfg)?; + return Ok(cfg); + } + + let content = std::fs::read_to_string(&path) + .with_context(|| anyhow!("failed to read config file {}", path.display()))?; + let cfg: StoredConfig = + serde_json::from_str(&content).context("failed to parse gsf config JSON")?; + Ok(cfg) +} + +fn save_config(cfg: &StoredConfig) -> anyhow::Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| anyhow!("failed to create config directory {}", parent.display()))?; + } + + let content = serde_json::to_string_pretty(cfg).context("failed to serialize config")?; + std::fs::write(&path, content) + .with_context(|| anyhow!("failed to write config {}", path.display()))?; + + Ok(()) +} + +fn get_param_from_config(cfg: &StoredConfig, param: Param) -> f64 { + match param { + Param::SensMult => cfg.sens_mult, + Param::YxRatio => cfg.yx_ratio, + Param::InputDpi => cfg.input_dpi, + Param::AngleRotation => cfg.angle_rotation, + Param::Accel => cfg.accel, + Param::OffsetLinear => cfg.offset_linear, + Param::OutputCap => cfg.output_cap, + Param::DecayRate => cfg.decay_rate, + Param::OffsetNatural => cfg.offset_natural, + Param::Limit => cfg.limit, + Param::Gamma => cfg.gamma, + Param::Smooth => cfg.smooth, + Param::Motivity => cfg.motivity, + Param::SyncSpeed => cfg.sync_speed, + } +} + +fn set_param_on_config(cfg: &mut StoredConfig, param: Param, value: f64) { + match param { + Param::SensMult => cfg.sens_mult = value, + Param::YxRatio => cfg.yx_ratio = value, + Param::InputDpi => cfg.input_dpi = value, + Param::AngleRotation => cfg.angle_rotation = value, + Param::Accel => cfg.accel = value, + Param::OffsetLinear => cfg.offset_linear = value, + Param::OutputCap => cfg.output_cap = value, + Param::DecayRate => cfg.decay_rate = value, + Param::OffsetNatural => cfg.offset_natural = value, + Param::Limit => cfg.limit = value, + Param::Gamma => cfg.gamma = value, + Param::Smooth => cfg.smooth = value, + Param::Motivity => cfg.motivity = value, + Param::SyncSpeed => cfg.sync_speed = value, + } +} + +impl Display for Fpt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format_param_value(f64::from(*self))) + } +} diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml new file mode 100644 index 0000000..dcf9f29 --- /dev/null +++ b/daemon/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gsf-daemon" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "gsfd" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +evdev = "0.13" +gsf-core = { path = "../crates/core" } diff --git a/daemon/src/main.rs b/daemon/src/main.rs new file mode 100644 index 0000000..e3cb425 --- /dev/null +++ b/daemon/src/main.rs @@ -0,0 +1,351 @@ +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 ScrollState { + last_scroll_event_at: Option, + remainder_v: f64, + remainder_h: f64, +} + +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 now = Instant::now(); + let dt = state + .last_scroll_event_at + .map(|t| now.saturating_duration_since(t).as_secs_f64()) + .unwrap_or(0.016) + .max(0.001); + state.last_scroll_event_at = Some(now); + + let speed = (value.abs() as f64) / dt; + 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 remainder = if axis == RelativeAxisCode::REL_HWHEEL + || axis == RelativeAxisCode::REL_HWHEEL_HI_RES + { + &mut state.remainder_h + } else { + &mut state.remainder_v + }; + + let scaled = (value as f64) * gain + *remainder; + let quantized = scaled.trunc() as i32; + *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); + } + + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime_dir).join("gsf-speed.txt"); + } + + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("state") + .join("gsf") + .join("speed.txt"); + } + + PathBuf::from("/tmp/gsf-speed.txt") +} diff --git a/packaging/systemd/user/gsfd.service b/packaging/systemd/user/gsfd.service new file mode 100644 index 0000000..6d7f1a5 --- /dev/null +++ b/packaging/systemd/user/gsfd.service @@ -0,0 +1,16 @@ +[Unit] +Description=gotta-scroll-fast daemon +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.cargo/bin/gsfd --match-name ploopy +Restart=on-failure +RestartSec=1 + +# Optional overrides: +# Environment=GSF_CONFIG_PATH=%h/.config/gsf/config.json +# Environment=GSF_STATS_PATH=%t/gsf-speed.txt + +[Install] +WantedBy=default.target