From 6e948d7b39e2717b7d5c09d5ac142be23a57b58a Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Tue, 24 Mar 2026 12:10:31 +0000 Subject: [PATCH] stabilize live speed indicator and normalize hi-res wheel handling --- crates/core/src/inputspeed.rs | 27 ++--- crates/core/src/persist.rs | 19 +++- daemon/src/main.rs | 26 ++--- tui/src/graph.rs | 205 ++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 tui/src/graph.rs diff --git a/crates/core/src/inputspeed.rs b/crates/core/src/inputspeed.rs index ce62b65..c24134e 100644 --- a/crates/core/src/inputspeed.rs +++ b/crates/core/src/inputspeed.rs @@ -8,8 +8,6 @@ use std::{ time::Duration, }; -use anyhow::Context; - static INPUT_SPEED_BITS: AtomicU64 = AtomicU64::new(0); pub fn read_input_speed() -> f64 { @@ -21,7 +19,16 @@ pub fn setup_input_speed_reader() -> JoinHandle> { let path = stats_path(); loop { - if let Ok(content) = std::fs::read_to_string(&path) + let stale = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .map(|age| age > Duration::from_millis(300)) + .unwrap_or(true); + + if stale { + INPUT_SPEED_BITS.store(0.0f64.to_bits(), Ordering::Relaxed); + } else 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); @@ -37,18 +44,6 @@ fn stats_path() -> PathBuf { 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"); - } - + // Keep default shared across sudo/non-sudo runs. PathBuf::from("/tmp/gsf-speed.txt") } diff --git a/crates/core/src/persist.rs b/crates/core/src/persist.rs index 59e9b1d..e1272f0 100644 --- a/crates/core/src/persist.rs +++ b/crates/core/src/persist.rs @@ -51,9 +51,9 @@ impl Default for StoredConfig { yx_ratio: 1.0, input_dpi: 1000.0, angle_rotation: 0.0, - accel: 0.0, - offset_linear: 0.0, - output_cap: 0.0, + accel: 0.3, + offset_linear: 2.0, + output_cap: 2.0, decay_rate: 0.1, offset_natural: 0.0, limit: 1.5, @@ -164,6 +164,19 @@ fn config_path() -> anyhow::Result { return Ok(PathBuf::from(p)); } + // If daemon is launched with sudo, prefer caller's home so CLI/TUI and daemon share config. + if let Ok(sudo_user) = std::env::var("SUDO_USER") + && !sudo_user.is_empty() + && sudo_user != "root" + { + let candidate = PathBuf::from("/home") + .join(sudo_user) + .join(".config") + .join("gsf") + .join("config.json"); + return Ok(candidate); + } + let home = std::env::var("HOME").context("HOME is not set")?; Ok(PathBuf::from(home) .join(".config") diff --git a/daemon/src/main.rs b/daemon/src/main.rs index e3cb425..0d98024 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -277,7 +277,18 @@ fn transform_event( .max(0.001); state.last_scroll_event_at = Some(now); - let speed = (value.abs() as f64) / dt; + 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 speed = normalized_value.abs() / dt; let (gain_x, gain_y) = sensitivity(speed, *mode, params); let gain = if axis == RelativeAxisCode::REL_HWHEEL || axis == RelativeAxisCode::REL_HWHEEL_HI_RES @@ -335,17 +346,6 @@ fn stats_path() -> PathBuf { 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"); - } - + // Keep default shared across sudo/non-sudo runs. PathBuf::from("/tmp/gsf-speed.txt") } diff --git a/tui/src/graph.rs b/tui/src/graph.rs new file mode 100644 index 0000000..97cb733 --- /dev/null +++ b/tui/src/graph.rs @@ -0,0 +1,205 @@ +use std::fmt::Debug; + +use gsf_core::{ContextRef, SensXY, get_param_value_from_ctx, persist::ParamStore, sensitivity}; + +use crate::{action, component::TuiComponent}; + +use crossterm::event::KeyEvent; +use ratatui::{prelude::*, widgets::*}; +use tracing::debug; + +#[derive(Debug, Default)] +struct LastMouseMove { + in_speed: f64, + out_sens_x: f64, + out_sens_y: f64, +} + +pub type GetNewYAxisBounds = dyn FnMut(ContextRef) -> [f64; 2]; + +pub struct SensitivityGraph { + last_mouse_move: LastMouseMove, + pub y_bounds: [f64; 2], + data: Vec<(f64, f64)>, + data_alt: Vec<(f64, f64)>, + title: &'static str, + data_name: String, + data_alt_name: String, + context: ContextRef, + on_y_axis_update_fn: Box>, +} + +impl SensitivityGraph { + pub fn new(context: ContextRef) -> Self { + Self { + context: context.clone(), + last_mouse_move: Default::default(), + y_bounds: [0.0, 0.0], + data: vec![], + data_alt: vec![], + title: "Sensitivity Graph (Ratio = Speed_out / Speed_in)", + data_name: "🠠🠢 Sens".to_string(), + data_alt_name: "🠡🠣 Sens".to_string(), + on_y_axis_update_fn: Box::new(|ctx| { + [ + 0.0, + f64::from(get_param_value_from_ctx!(ctx, SensMult)) * 2.0, + ] + }), + } + } + + pub fn on_y_axix_bounds_update( + mut self, + updater: impl FnMut(ContextRef) -> [f64; 2] + 'static, + ) -> Self { + self.on_y_axis_update_fn = Box::new(updater); + self + } + + fn update_data(&mut self) { + self.data.clear(); + self.data_alt.clear(); + + let params = self.context.get().params_snapshot(); + for x in (0..128).map(|x| (x as f64) * 1.0 /* step size */) { + let (sens_x, sens_y) = + gsf_core::sensitivity(x, self.context.get().current_mode, ¶ms); + self.data.push((x, sens_x)); + if sens_x != sens_y { + self.data_alt.push((x, sens_y)); + } + } + } + + fn read_input_speed_and_resolved_sens(&self) -> (f64, SensXY) { + let input_speed = gsf_core::inputspeed::read_input_speed(); + let params = self.context.get().params_snapshot(); + debug!("last mouse move read at {} counts/ms", input_speed); + ( + input_speed, + sensitivity(input_speed, self.context.get().current_mode, ¶ms), + ) + } + + fn update_last_move(&mut self) { + let (in_speed, out_sens) = self.read_input_speed_and_resolved_sens(); + self.last_mouse_move = LastMouseMove { + in_speed, + out_sens_x: out_sens.0, + out_sens_y: out_sens.1, + }; + } + + fn formatted_current_point(&self, x: f64, y: f64) -> String { + format!("• ({:0<6.3}, {:0<6.3})", x, y) + } +} + +impl TuiComponent for SensitivityGraph { + fn handle_key_event(&mut self, _event: &KeyEvent, _actions: &mut action::Actions) {} + + fn handle_mouse_event( + &mut self, + _event: &crossterm::event::MouseEvent, + _actions: &mut action::Actions, + ) { + } + + fn update(&mut self, action: &action::Action) { + if let action::Action::Tick = action { + debug!("updating graph on tick"); + self.update_last_move(); + self.update_data(); + self.y_bounds = (self.on_y_axis_update_fn)(self.context.clone()); + } + } + + fn draw(&self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) { + let (bounds, labels) = bounds_and_labels([0.0, 128.0], 16); + let x_axis = Axis::default() + .title("Speed_in".magenta()) + .style(Style::default().white()) + .bounds(bounds) + .labels(labels); + + let (bounds, labels) = bounds_and_labels(self.y_bounds, 10); + let y_axis = Axis::default() + .title("Ratio".magenta()) + .style(Style::default().white()) + .bounds(bounds) + .labels(labels); + + let clamped_x = self.last_mouse_move.in_speed.clamp(0.0, 128.0); + let highlight_point_x = &[(clamped_x, self.last_mouse_move.out_sens_x)]; + let highlight_point_y = &[(clamped_x, self.last_mouse_move.out_sens_y)]; + let mut chart_plots = vec![ + Dataset::default() + .name(if self.data_alt.is_empty() { + "🠠🠢 🠡🠣Sens".to_owned() + } else { + self.data_name.clone() + }) + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().green()) + .data(&self.data), + // current instance of user input speed and output sensitivity + Dataset::default() + .name(self.formatted_current_point( + self.last_mouse_move.in_speed, + self.last_mouse_move.out_sens_x, + )) + .marker(symbols::Marker::Braille) + .style(Style::default().red()) + .data(highlight_point_x), + ]; + + if !self.data_alt.is_empty() { + chart_plots.push( + Dataset::default() + .name(self.data_alt_name.clone()) + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().yellow()) + .data(&self.data_alt), + ); + + chart_plots.push( + Dataset::default() + .name(self.formatted_current_point( + self.last_mouse_move.in_speed, + self.last_mouse_move.out_sens_y, + )) + .marker(symbols::Marker::Braille) + .style(Style::default().blue().bold()) + .data(highlight_point_y), + ); + } + + let chart = Chart::new(chart_plots).x_axis(x_axis).y_axis(y_axis); + + frame.render_widget( + chart.block( + Block::default() + .borders(Borders::NONE) + .title(self.title) + .bold(), + ), + area, + ); + } +} + +fn bounds_and_labels(bounds: [f64; 2], div: usize) -> ([f64; 2], Vec>) { + let [o, f] = bounds; + let d = f - o; + let step = d / (div as f64); + + let labels = (0..=div) + .map(|i| o + i as f64 * step) + .map(|label| format!("{:.2}", label).into()) + .collect(); + + (bounds, labels) +}