From 20620e3b145f45585faee5e449ee601075d62bdf Mon Sep 17 00:00:00 2001 From: Adiakys Date: Wed, 4 Nov 2026 00:45:23 +0100 Subject: [PATCH 1/4] Read each device's axes once per sample --- crates/simrig-plugin-kit/src/input.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/simrig-plugin-kit/src/input.rs b/crates/simrig-plugin-kit/src/input.rs index 460baa4..5a008db 100644 --- a/crates/simrig-plugin-kit/src/input.rs +++ b/crates/simrig-plugin-kit/src/input.rs @@ -6,10 +6,12 @@ //! (force feedback, and the controls the driver takes). Levels set on a fake device are written //! to `simrig-fake-ff`, as `gain ` and `autocenter ` lines. +use std::cell::RefCell; use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use evdev::{ Device, FFEffect, FFEffectCode, FFEffectData, FFEffectKind, FFEnvelope, FFReplay, FFTrigger, @@ -24,6 +26,10 @@ const BTN_JOYSTICK: u16 = 0x120; /// The hat switches' axes: `ABS_HAT0X` to `ABS_HAT3Y`, in x/y pairs. const ABS_HAT0X: u16 = 0x10; const ABS_HAT3Y: u16 = 0x17; +/// Axes read within this time of each other are one reading: the components of a device (a +/// base, its wheel and pedals) read their inputs one after the other, through several axes each, +/// and the kernel takes one call per axis. +const SAMPLE: Duration = Duration::from_millis(2); /// Buttons in the order games number them. pub fn game_order(keys: &mut [(u16, bool)]) { @@ -110,6 +116,8 @@ pub enum Inputs { device: Box, /// The force feedback test running, stopped when dropped. effect: Option, + /// The last reading of the axes, and when it was taken. + axes: RefCell)>>, }, Fake(PathBuf), None(String), @@ -127,6 +135,7 @@ impl Inputs { Ok(device) => Inputs::Device { device: Box::new(device), effect: None, + axes: RefCell::default(), }, Err(e) => Inputs::None(format!("Cannot read {}: {e}.", path.display())), } @@ -143,13 +152,18 @@ impl Inputs { /// Every absolute axis by evdev code. pub fn axes(&self) -> BTreeMap { match self { - Inputs::Device { device, .. } => { + Inputs::Device { device, axes, .. } => { + if let Some((at, read)) = &*axes.borrow() + && at.elapsed() < SAMPLE + { + return read.clone(); + } let (Some(supported), Ok(state)) = (device.supported_absolute_axes(), device.get_abs_state()) else { return BTreeMap::new(); }; - supported + let read: BTreeMap = supported .iter() .filter_map(|axis| { let info = state.get(usize::from(axis.0))?; @@ -162,7 +176,9 @@ impl Inputs { }, )) }) - .collect() + .collect(); + *axes.borrow_mut() = Some((Instant::now(), read.clone())); + read } Inputs::Fake(path) => fake(path) .into_iter() @@ -274,7 +290,7 @@ impl Inputs { /// A gentle side-to-side test: a sine of a quarter of full strength for two seconds. pub fn test_effect(&mut self) -> io::Result<()> { - let Inputs::Device { device, effect } = self else { + let Inputs::Device { device, effect, .. } = self else { // Nothing to move on fake or missing devices. return Ok(()); }; From 6a6ea3d2320ba077d6ec388bda75d70e140483f6 Mon Sep 17 00:00:00 2001 From: Adiakys Date: Wed, 4 Nov 2026 00:45:23 +0100 Subject: [PATCH 2/4] Send live input states only when they change --- crates/simrig-cli/src/main.rs | 8 +++++++- crates/simrig-plugin-sdk/src/server.rs | 28 ++++++++++++++++++-------- crates/simrig-protocol/src/lib.rs | 3 ++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/simrig-cli/src/main.rs b/crates/simrig-cli/src/main.rs index 6e89948..892359f 100644 --- a/crates/simrig-cli/src/main.rs +++ b/crates/simrig-cli/src/main.rs @@ -388,10 +388,16 @@ fn watch(host: &mut Host, component: &str, rate: f64, seconds: Option) -> R let end = seconds.map(|s| Instant::now() + Duration::from_secs_f64(s)); let tty = io::stdout().is_terminal(); let mut out = io::stdout().lock(); + let mut first = true; while end.is_none_or(|end| Instant::now() < end) { + // States come when the inputs change: after the first, silence is a still device. let Some(values) = host.next_input_state(&address, Duration::from_secs(2))? else { - bail!("no input state received"); + if first { + bail!("no input state received"); + } + continue; }; + first = false; if tty { write!(out, "\x1b[H\x1b[2J")?; } diff --git a/crates/simrig-plugin-sdk/src/server.rs b/crates/simrig-plugin-sdk/src/server.rs index 28dc970..af63314 100644 --- a/crates/simrig-plugin-sdk/src/server.rs +++ b/crates/simrig-plugin-sdk/src/server.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value as Json; -use simrig_model::DeviceInfo; +use simrig_model::{DeviceInfo, Value}; use simrig_protocol::{ API_VERSION, ActionParams, AttachParams, AttachResult, ComponentParams, ComponentsChanged, DetachParams, DevicesChanged, DiscoverResult, InitializeParams, InitializeResult, InputState, @@ -122,6 +122,9 @@ struct Attached { struct Subscription { interval: Duration, next: Instant, + /// The values last sent: a state is sent only when they change, so a still device costs the + /// host nothing. + sent: Option>, } /// Protocol server around an [`Adapter`]. [`run`] wires it to stdio; tests can drive it @@ -377,6 +380,7 @@ impl Server { Subscription { interval, next: Instant::now(), + sent: None, }, ); reply(()) @@ -687,14 +691,22 @@ impl Server { if let Some(sub) = self.subscriptions.get_mut(&id) { sub.next = now + sub.interval; } - if let Ok(c) = self.component(&id) { - let values = dispatch::read_inputs(c); - let state = InputState { - component: id, - values, - }; - write_message(out, &Message::notification(method::INPUT_STATE, state))?; + let Ok(c) = self.component(&id) else { + continue; + }; + let values = dispatch::read_inputs(c); + let Some(sub) = self.subscriptions.get_mut(&id) else { + continue; + }; + if sub.sent.as_ref() == Some(&values) { + continue; } + sub.sent = Some(values.clone()); + let state = InputState { + component: id, + values, + }; + write_message(out, &Message::notification(method::INPUT_STATE, state))?; } Ok(()) } diff --git a/crates/simrig-protocol/src/lib.rs b/crates/simrig-protocol/src/lib.rs index 1c6923a..36726be 100644 --- a/crates/simrig-protocol/src/lib.rs +++ b/crates/simrig-protocol/src/lib.rs @@ -37,7 +37,8 @@ pub mod method { pub const OUTPUT_WRITE: &str = "output/write"; /// host → plugin: read all supported inputs of a component once. pub const INPUT_READ: &str = "input/read"; - /// host → plugin: start sending `input/state` notifications at a capped rate. + /// host → plugin: start sending `input/state` notifications, when the inputs change and at + /// most at the rate asked. Subscribing again changes the rate. pub const INPUT_SUBSCRIBE: &str = "input/subscribe"; /// host → plugin: stop sending `input/state` notifications. pub const INPUT_UNSUBSCRIBE: &str = "input/unsubscribe"; From 9f887d9c96647ff23b027b5fe984329d178b2bec Mon Sep 17 00:00:00 2001 From: Adiakys Date: Wed, 4 Nov 2026 00:45:23 +0100 Subject: [PATCH 3/4] Read still axis-only components at 10 Hz, and a graphics mode for 30 or 60 Hz --- DESIGN.md | 8 +- PRODUCT.md | 2 +- crates/simrig-gui/src/app.rs | 35 ++++-- crates/simrig-gui/src/backend.rs | 44 ++++--- crates/simrig-gui/src/flows.rs | 9 +- crates/simrig-gui/src/main.rs | 7 +- crates/simrig-gui/src/sampling.rs | 190 +++++++++++++++++++++++++++++ crates/simrig-gui/src/settings.rs | 88 ++++++++++++- crates/simrig-gui/ui/api.slint | 4 +- crates/simrig-gui/ui/devices.slint | 6 +- crates/simrig-gui/ui/flows.slint | 13 +- docs/architecture.md | 2 +- docs/design/flows.md | 3 +- 13 files changed, 360 insertions(+), 51 deletions(-) create mode 100644 crates/simrig-gui/src/sampling.rs diff --git a/DESIGN.md b/DESIGN.md index 350e99e..4a4a70d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -168,11 +168,13 @@ the **generic SaaS dashboard** (identical cards, purple accents, giant hero metr - Unsupported items stay visible, disabled, with their reason in plain words. - Motion only for information: devices move live; screens change in 150 to 250 ms. -**Motion.** Live device animation follows the hardware at display rate, with no easing of its -own (it is data). Interface transitions use ease-out-quart (`cubic-bezier(0.25, 1, 0.5, 1)`): +**Motion.** Live device animation follows the hardware at the graphics mode's rate (30 Hz, or +60 Hz when chosen), with no easing of its own (it is data). Interface transitions use ease-out-quart (`cubic-bezier(0.25, 1, 0.5, 1)`): 120 ms for feedback, 200 ms for state changes, 320 ms for the home-to-settings transition where the device image travels to its new place. No bounce, no staggered page loads, no idle loops. -With reduced motion, interface transitions become instant cross-fades; live devices keep moving. +With reduced motion, interface transitions become instant cross-fades and the drawings that turn +(the steering wheel, the handbrake lever) stay still; numbers, pedal bars and the gear keep +following the devices. **Layout.** Fixed spacing scale on a 4 px base. Desktop windows from 1280×720 up; the home recomposes (not shrinks) below 1280 px wide by stacking the right column under the centre. diff --git a/PRODUCT.md b/PRODUCT.md index 9b1f9b4..131de4e 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -47,6 +47,6 @@ What to keep from manufacturers' apps: **big, readable live values** (angle, ped - WCAG 2.2 AA contrast in both themes, including live values over device images. - State is never conveyed by color alone (color-blind safe): pair color with shape, icon, text or position. -- Reduced motion respected: live device animation stays (it is information), decorative transitions are removed. +- Reduced motion respected: decorative transitions are removed and turning drawings (steering wheel, handbrake lever) stay still; live numbers and bars stay (they are information). - Fully usable with the keyboard, visible focus everywhere. - Text and labels are translated (English and Italian at first); layouts must tolerate longer strings. diff --git a/crates/simrig-gui/src/app.rs b/crates/simrig-gui/src/app.rs index efa78bc..596587b 100644 --- a/crates/simrig-gui/src/app.rs +++ b/crates/simrig-gui/src/app.rs @@ -14,7 +14,7 @@ use simrig_model::{ComponentKind, ItemSpec, PedalRole, Support, Value}; use crate::backend::{Backend, Command, ComponentView, Snapshot, Update}; use crate::devices::{GateLayout, Geometry}; -use crate::settings::{GuiSettings, ThemeMode}; +use crate::settings::{GraphicsMode, GuiSettings, ThemeMode}; use crate::ui::{self, AppWindow, Device, GateLabel, Rig, Screen}; pub struct App { @@ -134,7 +134,8 @@ impl App { } let theme = window.global::(); theme.set_mode(settings.theme.index()); - theme.set_reduced_motion(settings.reduced_motion); + theme.set_reduced_motion(settings.graphics.reduced_motion()); + backend.send(Command::LiveRate(settings.graphics.live_rate())); let geometry = Geometry::load(); set_geometry(window, &geometry); @@ -457,9 +458,13 @@ impl App { device.pressed_symbol = symbol.into(); } let pivot = self.geometry.steering_wheel.pivot; + let angle = if self.settings.graphics.reduced_motion() { + 0.0 + } else { + device.angle + }; if let Some(raster) = &mut self.raster { - device.frame = - raster.wheel(&device.rim, device.angle, (pivot[0], pivot[1])); + device.frame = raster.wheel(&device.rim, angle, (pivot[0], pivot[1])); } } } @@ -581,11 +586,15 @@ impl App { fn handbrake_drawing(&mut self, device: &mut Device, pull: f32) { device.pull = pull; let lever = &self.geometry.handbrake.lever; + // Reduced motion: the pull stays in the numbers, the lever stays down. + let drawn = if self.settings.graphics.reduced_motion() { + 0.0 + } else { + pull.clamp(0.0, 1.0) + }; if let Some(raster) = &mut self.raster { - device.frame = raster.lever( - pull.clamp(0.0, 1.0) * lever.pulled_angle, - (lever.pivot[0], lever.pivot[1]), - ); + device.frame = + raster.lever(drawn * lever.pulled_angle, (lever.pivot[0], lever.pivot[1])); } device.pull_text = if pull < 0.02 { "Released".into() @@ -739,10 +748,14 @@ impl App { self.window().global::().set_mode(mode.index()); } - pub fn set_reduced_motion(&mut self, on: bool) { - self.settings.reduced_motion = on; + pub fn set_graphics(&mut self, mode: GraphicsMode) { + self.settings.graphics = mode; self.save_settings(); - self.window().global::().set_reduced_motion(on); + self.window() + .global::() + .set_reduced_motion(mode.reduced_motion()); + self.send(Command::LiveRate(mode.live_rate())); + self.update_cards(|_| true); } // ------------------------------------------------------------------ toasts diff --git a/crates/simrig-gui/src/backend.rs b/crates/simrig-gui/src/backend.rs index c13ffdf..fb295c0 100644 --- a/crates/simrig-gui/src/backend.rs +++ b/crates/simrig-gui/src/backend.rs @@ -2,7 +2,7 @@ //! never freeze the interface. The interface sends [`Command`]s; the thread answers with //! [`Update`]s, delivered on the UI thread by the `deliver` callback. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -13,9 +13,9 @@ use simrig_host::{ }; use simrig_model::{DeviceInfo, ProcedureState, Value}; -/// Live inputs per second, for every component of the rig. -const LIVE_RATE_HZ: f64 = 60.0; -const FRAME: Duration = Duration::from_millis(16); +use crate::sampling::{STILL_HZ, Sampling}; +use crate::settings::GraphicsMode; + /// USB hotplug is checked less often than plugins. const USB_SCAN: Duration = Duration::from_millis(750); /// What games need is checked again now and then too: access and drivers change with udev, not @@ -23,6 +23,8 @@ const USB_SCAN: Duration = Duration::from_millis(750); const DEVICE_CHECKS: Duration = Duration::from_secs(3); pub enum Command { + /// Live inputs read and drawn this many times a second (the setting). + LiveRate(f64), Set { address: String, item: String, @@ -225,10 +227,10 @@ struct Dirty { fn run(options: HostOptions, rx: Receiver, deliver: impl Fn(Update)) { let mut host = Host::start(&options); - let mut subscribed: HashSet = HashSet::new(); + let mut sampling = Sampling::new(GraphicsMode::default().live_rate()); let mut drivers: Drivers = BTreeMap::new(); let system = host.system(); - resubscribe(&mut host, &mut subscribed); + resubscribe(&mut host, &mut sampling); refresh_drivers(&host, &mut drivers); let full = |host: &mut Host, drivers: &Drivers, findings: &mut Vec| { let mut s = snapshot(host, drivers); @@ -245,7 +247,8 @@ fn run(options: HostOptions, rx: Receiver, deliver: impl Fn(Update)) { let mut last_scan = Instant::now(); let mut last_check = Instant::now(); loop { - let wait = FRAME.saturating_sub(last_frame.elapsed()); + let frame = Duration::from_secs_f64(1.0 / sampling.live()); + let wait = frame.saturating_sub(last_frame.elapsed()); let mut dirty = Dirty::default(); match rx.recv_timeout(wait) { Ok(command) => { @@ -254,6 +257,12 @@ fn run(options: HostOptions, rx: Receiver, deliver: impl Fn(Update)) { batch.extend(rx.try_iter()); let batch = coalesce(batch); for command in batch { + if let Command::LiveRate(rate) = command { + for address in sampling.set_live(rate) { + let _ = host.subscribe(&address, rate); + } + continue; + } execute(&mut host, command, &deliver, &mut dirty); } } @@ -270,6 +279,9 @@ fn run(options: HostOptions, rx: Receiver, deliver: impl Fn(Update)) { for event in events { match event { HostEvent::Inputs { address, values } => { + if let Some(rate) = sampling.inputs(&address, &values, Instant::now()) { + let _ = host.subscribe(&address, rate); + } live.entry(address).or_default().extend(values); } HostEvent::ProcedureUpdated { session, state } => { @@ -290,8 +302,11 @@ fn run(options: HostOptions, rx: Receiver, deliver: impl Fn(Update)) { if dirty.drivers { refresh_drivers(&host, &mut drivers); } + for address in sampling.still(Instant::now()) { + let _ = host.subscribe(&address, STILL_HZ); + } if dirty.rig || dirty.drivers { - resubscribe(&mut host, &mut subscribed); + resubscribe(&mut host, &mut sampling); deliver(Update::Snapshot(full(&mut host, &drivers, &mut findings))); last_check = Instant::now(); } else { @@ -309,7 +324,7 @@ fn run(options: HostOptions, rx: Receiver, deliver: impl Fn(Update)) { } } } - if last_frame.elapsed() >= FRAME { + if last_frame.elapsed() >= frame { last_frame = Instant::now(); if !live.is_empty() { deliver(Update::Live( @@ -343,16 +358,15 @@ fn coalesce(batch: Vec) -> Vec { } /// Subscribes to the live inputs of every component not subscribed yet. -fn resubscribe(host: &mut Host, subscribed: &mut HashSet) { +fn resubscribe(host: &mut Host, sampling: &mut Sampling) { let addresses: Vec = host .components() .iter() .map(|c| c.address.clone()) .collect(); - subscribed.retain(|a| addresses.contains(a)); - for address in addresses { - if !subscribed.contains(&address) && host.subscribe(&address, LIVE_RATE_HZ).is_ok() { - subscribed.insert(address); + for address in sampling.missing(&addresses) { + if host.subscribe(&address, sampling.live()).is_ok() { + sampling.add(&address, Instant::now()); } } } @@ -517,6 +531,8 @@ fn snapshot(host: &mut Host, drivers: &Drivers) -> Snapshot { fn execute(host: &mut Host, command: Command, deliver: &impl Fn(Update), dirty: &mut Dirty) { let fail = |text: String| deliver(Update::Done { ok: false, text }); let rig = match command { + // The loop follows the rate itself. + Command::LiveRate(_) => false, Command::Set { address, item, diff --git a/crates/simrig-gui/src/flows.rs b/crates/simrig-gui/src/flows.rs index 8e1b5e7..fdcf0dd 100644 --- a/crates/simrig-gui/src/flows.rs +++ b/crates/simrig-gui/src/flows.rs @@ -10,7 +10,7 @@ use simrig_model::{Bus, ComponentKind, ProcedureDescriptor}; use crate::app::App; use crate::backend::{Command, PluginView, Snapshot, UsbView}; -use crate::settings::ThemeMode; +use crate::settings::{GraphicsMode, ThemeMode}; use crate::ui::{self, Rig, Screen}; /// Steps of the add-device flow. @@ -787,7 +787,7 @@ impl App { rig.set_usb_devices(ModelRc::new(VecModel::from(usb))); rig.set_composition(ModelRc::new(VecModel::from(rows))); rig.set_theme_index(self.settings.theme.index()); - rig.set_reduced_motion(self.settings.reduced_motion); + rig.set_graphics_index(self.settings.graphics.index()); } pub fn set_excluded(&mut self, address: &str, excluded: bool) { @@ -838,6 +838,11 @@ impl App { self.refresh_settings(); } + pub fn set_graphics_index(&mut self, index: i32) { + self.set_graphics(GraphicsMode::from_index(index)); + self.refresh_settings(); + } + // ------------------------------------------------------------------ first run pub fn needs_first_run(&self) -> bool { diff --git a/crates/simrig-gui/src/main.rs b/crates/simrig-gui/src/main.rs index a9d84fb..20aa3f9 100644 --- a/crates/simrig-gui/src/main.rs +++ b/crates/simrig-gui/src/main.rs @@ -12,6 +12,7 @@ mod inputs; mod procedure; mod raster; mod report; +mod sampling; mod settings; mod shot; mod update; @@ -297,11 +298,7 @@ mod callbacks { let a = app.clone(); api.on_set_theme(move |i| a.borrow_mut().set_theme_index(i)); let a = app.clone(); - api.on_set_reduced_motion(move |on| { - let mut a = a.borrow_mut(); - a.set_reduced_motion(on); - a.refresh_flows(); - }); + api.on_set_graphics(move |i| a.borrow_mut().set_graphics_index(i)); let a = app.clone(); api.on_choose_plugin(move |key, i| a.borrow_mut().choose_plugin(&key, i)); let a = app.clone(); diff --git a/crates/simrig-gui/src/sampling.rs b/crates/simrig-gui/src/sampling.rs new file mode 100644 index 0000000..5c5d1f1 --- /dev/null +++ b/crates/simrig-gui/src/sampling.rs @@ -0,0 +1,190 @@ +//! How often the plugins read each component's live inputs. A component with buttons, hats or a +//! gear is read at the live rate all the time: a quick press would fall between two slower +//! readings and be lost. A component with axes only (a base's angle, pedals, a handbrake) is read +//! slowly while it stays still, and at the live rate as soon as it moves. + +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use simrig_model::Value; + +/// Readings per second of a component with axes only, while it stays still. +pub const STILL_HZ: f64 = 10.0; +/// How long a component with axes only stays at the live rate after it last moved. +const STILL_AFTER: Duration = Duration::from_secs(1); + +struct Component { + /// Read at the live rate (else at [`STILL_HZ`]). + fast: bool, + /// It has inputs a slower reading would miss; known from its first state. + switches: Option, + values: BTreeMap, + moved: Instant, +} + +pub struct Sampling { + /// Readings per second of the components that need them: the app's live rate. + live: f64, + components: HashMap, +} + +impl Sampling { + pub fn new(live: f64) -> Self { + Self { + live, + components: HashMap::new(), + } + } + + pub fn live(&self) -> f64 { + self.live + } + + /// Forgets the components that went away and returns the ones not followed yet, to subscribe + /// at the live rate. + pub fn missing(&mut self, addresses: &[String]) -> Vec { + self.components.retain(|a, _| addresses.contains(a)); + addresses + .iter() + .filter(|a| !self.components.contains_key(*a)) + .cloned() + .collect() + } + + /// A component subscribed at the live rate. + pub fn add(&mut self, address: &str, now: Instant) { + self.components.insert( + address.to_string(), + Component { + fast: true, + switches: None, + values: BTreeMap::new(), + moved: now, + }, + ); + } + + /// A component sent its inputs: the rate to subscribe it at, when it must change. + pub fn inputs( + &mut self, + address: &str, + values: &BTreeMap, + now: Instant, + ) -> Option { + let c = self.components.get_mut(address)?; + let first = c.switches.is_none(); + if first { + c.switches = Some(values.values().any(is_switch)); + } + if c.values == *values { + return None; + } + c.values = values.clone(); + if first { + return None; + } + c.moved = now; + if c.fast { + return None; + } + c.fast = true; + Some(self.live) + } + + /// The components with axes only that stayed still long enough, to subscribe at + /// [`STILL_HZ`]. + pub fn still(&mut self, now: Instant) -> Vec { + self.components + .iter_mut() + .filter(|(_, c)| { + c.fast && c.switches == Some(false) && now.duration_since(c.moved) >= STILL_AFTER + }) + .map(|(address, c)| { + c.fast = false; + address.clone() + }) + .collect() + } + + /// The live rate changed: the components read at it, to subscribe at the new one. + pub fn set_live(&mut self, live: f64) -> Vec { + self.live = live; + self.components + .iter() + .filter(|(_, c)| c.fast) + .map(|(address, _)| address.clone()) + .collect() + } +} + +/// An input that changes in a moment and back: a button, a hat, a gear, an encoder's steps. +fn is_switch(value: &Value) -> bool { + matches!( + value, + Value::Bool(_) | Value::Int(_) | Value::Gear(_) | Value::Buttons(_) | Value::Ints(_) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(items: &[(&str, Value)]) -> BTreeMap { + items + .iter() + .map(|(id, v)| (id.to_string(), v.clone())) + .collect() + } + + #[test] + fn axes_slow_down_when_still_and_speed_up_when_moved() { + let t0 = Instant::now(); + let mut s = Sampling::new(30.0); + let rig = ["pedals".to_string()]; + assert_eq!(s.missing(&rig), rig); + s.add("pedals", t0); + assert!(s.missing(&rig).is_empty()); + + let rest = state(&[("brake.position", Value::Float(0.0))]); + assert_eq!(s.inputs("pedals", &rest, t0), None); + assert!(s.still(t0 + Duration::from_millis(500)).is_empty()); + assert_eq!(s.still(t0 + STILL_AFTER), rig); + // Already slow, and the same state again is no move. + assert!(s.still(t0 + STILL_AFTER * 2).is_empty()); + assert_eq!(s.inputs("pedals", &rest, t0 + STILL_AFTER * 2), None); + + let pressed = state(&[("brake.position", Value::Float(0.4))]); + let t1 = t0 + STILL_AFTER * 3; + assert_eq!(s.inputs("pedals", &pressed, t1), Some(30.0)); + assert_eq!(s.inputs("pedals", &rest, t1), None, "already fast"); + assert!(s.still(t1 + Duration::from_millis(500)).is_empty()); + assert_eq!(s.still(t1 + STILL_AFTER), rig); + } + + #[test] + fn buttons_stay_at_the_live_rate() { + let t0 = Instant::now(); + let mut s = Sampling::new(30.0); + s.add("wheel", t0); + let buttons = state(&[("buttons", Value::Buttons(vec![false; 4]))]); + assert_eq!(s.inputs("wheel", &buttons, t0), None); + assert!(s.still(t0 + STILL_AFTER * 5).is_empty()); + } + + #[test] + fn the_live_rate_follows_the_setting() { + let t0 = Instant::now(); + let mut s = Sampling::new(30.0); + s.add("wheel", t0); + s.add("pedals", t0); + s.inputs("pedals", &state(&[("x", Value::Float(0.0))]), t0); + s.still(t0 + STILL_AFTER); + assert_eq!(s.set_live(60.0), ["wheel"]); + assert_eq!(s.live(), 60.0); + let moved = state(&[("x", Value::Float(1.0))]); + assert_eq!(s.inputs("pedals", &moved, t0 + STILL_AFTER), Some(60.0)); + // Gone from the rig: forgotten. + assert_eq!(s.missing(&["wheel".to_string()]), Vec::::new()); + assert_eq!(s.set_live(30.0), ["wheel"]); + } +} diff --git a/crates/simrig-gui/src/settings.rs b/crates/simrig-gui/src/settings.rs index 4136292..d8860b2 100644 --- a/crates/simrig-gui/src/settings.rs +++ b/crates/simrig-gui/src/settings.rs @@ -33,12 +33,57 @@ impl ThemeMode { } } +/// How the app draws: how often devices are read and drawn, and whether anything moves on +/// its own. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphicsMode { + /// No transitions, and the steering wheel drawing stays straight. + ReducedMotion, + #[default] + Hz30, + Hz60, +} + +impl GraphicsMode { + pub fn index(self) -> i32 { + match self { + GraphicsMode::ReducedMotion => 0, + GraphicsMode::Hz30 => 1, + GraphicsMode::Hz60 => 2, + } + } + + pub fn from_index(index: i32) -> Self { + match index { + 0 => GraphicsMode::ReducedMotion, + 2 => GraphicsMode::Hz60, + _ => GraphicsMode::Hz30, + } + } + + /// Live inputs read and drawn per second. + pub fn live_rate(self) -> f64 { + match self { + GraphicsMode::Hz60 => 60.0, + _ => 30.0, + } + } + + pub fn reduced_motion(self) -> bool { + self == GraphicsMode::ReducedMotion + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct GuiSettings { #[serde(default)] pub theme: ThemeMode, #[serde(default)] - pub reduced_motion: bool, + pub graphics: GraphicsMode, + /// The switch that came before [`GraphicsMode`]: read once into it, never written. + #[serde(default, skip_serializing)] + reduced_motion: bool, /// The first-run flow was completed or skipped. #[serde(default)] pub setup_done: bool, @@ -58,10 +103,14 @@ impl GuiSettings { /// Missing or unreadable settings fall back to the defaults: they are only preferences. pub fn load(config_dir: &Path) -> Self { - fs::read_to_string(Self::path(config_dir)) + let mut settings: Self = fs::read_to_string(Self::path(config_dir)) .ok() .and_then(|text| toml::from_str(&text).ok()) - .unwrap_or_default() + .unwrap_or_default(); + if std::mem::take(&mut settings.reduced_motion) { + settings.graphics = GraphicsMode::ReducedMotion; + } + settings } pub fn save(&self, config_dir: &Path) -> std::io::Result<()> { @@ -70,3 +119,36 @@ impl GuiSettings { fs::write(Self::path(config_dir), text) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduce_motion_becomes_the_graphics_mode() { + let dir = std::env::temp_dir().join(format!("simrig-gui-settings-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("gui.toml"), "reduced_motion = true\n").unwrap(); + let settings = GuiSettings::load(&dir); + assert_eq!(settings.graphics, GraphicsMode::ReducedMotion); + settings.save(&dir).unwrap(); + let text = fs::read_to_string(dir.join("gui.toml")).unwrap(); + assert!(text.contains("graphics = \"reduced_motion\""), "{text}"); + assert!(!text.contains("reduced_motion = "), "{text}"); + assert_eq!( + GuiSettings::load(&dir).graphics, + GraphicsMode::ReducedMotion + ); + fs::remove_dir_all(dir).unwrap(); + + assert_eq!(GuiSettings::default().graphics.live_rate(), 30.0); + assert_eq!(GraphicsMode::Hz60.live_rate(), 60.0); + for mode in [ + GraphicsMode::ReducedMotion, + GraphicsMode::Hz30, + GraphicsMode::Hz60, + ] { + assert_eq!(GraphicsMode::from_index(mode.index()), mode); + } + } +} diff --git a/crates/simrig-gui/ui/api.slint b/crates/simrig-gui/ui/api.slint index d92e036..9efe221 100644 --- a/crates/simrig-gui/ui/api.slint +++ b/crates/simrig-gui/ui/api.slint @@ -363,7 +363,7 @@ export global Rig { /// Installed drivers with an update: a dot on Plugins. in property drivers-outdated; in property theme-index; - in property reduced-motion; + in property graphics-index; in property first-step; in property <[FoundDevice]> first-found; in property first-busy; @@ -420,7 +420,7 @@ export global Api { callback restart-plugin(string); callback set-excluded(string, bool); callback set-theme(int); - callback set-reduced-motion(bool); + callback set-graphics(int); callback first-run-done(); callback first-install-drivers(); callback first-next(); diff --git a/crates/simrig-gui/ui/devices.slint b/crates/simrig-gui/ui/devices.slint index 398da7b..6fc7d6c 100644 --- a/crates/simrig-gui/ui/devices.slint +++ b/crates/simrig-gui/ui/devices.slint @@ -179,7 +179,8 @@ export component LiveDevice { x: Geometry.wheel-pivot-x * root.s * 1px, y: Geometry.wheel-pivot-y * root.s * 1px, }; - transform-rotation: root.device.angle * 1deg; + // Reduced motion: the angle stays in the numbers, the drawing stays straight. + transform-rotation: Theme.reduced-motion ? 0deg : root.device.angle * 1deg; } if !root.custom && root.device.kind == "pedals": Rectangle { @@ -265,7 +266,8 @@ export component LiveDevice { x: Geometry.lever-pivot-x * root.s * 1px, y: Geometry.lever-pivot-y * root.s * 1px, }; - transform-rotation: clamp(root.device.pull, 0, 1) * Geometry.lever-angle * 1deg; + transform-rotation: Theme.reduced-motion ? 0deg + : clamp(root.device.pull, 0, 1) * Geometry.lever-angle * 1deg; } } } diff --git a/crates/simrig-gui/ui/flows.slint b/crates/simrig-gui/ui/flows.slint index f107f22..b9078e5 100644 --- a/crates/simrig-gui/ui/flows.slint +++ b/crates/simrig-gui/ui/flows.slint @@ -687,12 +687,13 @@ export component SettingsScreen inherits Rectangle { } } SettingsLine { - label: @tr("Reduce motion"); - help: @tr("Screens change without animations. Devices keep moving: that is information."); - Toggle { - checked: Rig.reduced-motion; - label: @tr("Reduce motion"); - toggled(on) => { Api.set-reduced-motion(on); } + label: @tr("Graphics"); + help: @tr("How often devices are read and drawn. 60 Hz moves more smoothly, for twice the processor and graphics work. Reduced motion draws at 30 Hz, changes screens without animations and keeps the steering wheel and handbrake drawings still: numbers and pedal bars still follow the devices."); + Segmented { + options: [@tr("Reduced motion"), @tr("30 Hz"), @tr("60 Hz")]; + selected: Rig.graphics-index; + label: @tr("Graphics"); + picked(i) => { Api.set-graphics(i); } } } SettingsLine { diff --git a/docs/architecture.md b/docs/architecture.md index 5e2db3b..47aa31f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -507,7 +507,7 @@ kernel_driver = "fanatec" # optional: the HID driver games need | app → plugin | `action/run` | standard actions: calibration, centering, test effect, stop force | | app → plugin | `output/write` | standard outputs: LEDs, display | | app → plugin | `input/read` | all supported inputs once | -| app → plugin | `input/subscribe` `input/unsubscribe` → `input/state` | live inputs at a capped rate | +| app → plugin | `input/subscribe` `input/unsubscribe` → `input/state` | live inputs when they change, at a capped rate; subscribing again changes it (the app reads components with axes only at 10 Hz while they stay still) | | plugin → app | `params/changed` | fresh description after a set or action (availability may have changed) | | app → plugin | `shutdown` | the app is closing | | plugin → app | `components/changed` | components of a device changed (rim swap), with their descriptions | diff --git a/docs/design/flows.md b/docs/design/flows.md index 689888a..97a13d7 100644 --- a/docs/design/flows.md +++ b/docs/design/flows.md @@ -133,7 +133,8 @@ From **Plugins** in the top bar. From **Settings** in the top bar. -- Theme (system, light, dark), language, units (degrees, percent), reduced motion. +- Theme (system, light, dark), language, units (degrees, percent), graphics (reduced motion, + 30 Hz, 60 Hz). - Rig composition: excluded components (with **Include again**), devices added from the catalog before plugging them in. - Devices: for each USB device, the plugin that handles it (**Automatic**, or one of the plugins From b2694bf58f66e60854155b4d1a8bb43568f827f5 Mon Sep 17 00:00:00 2001 From: Adiakys Date: Wed, 4 Nov 2026 00:59:41 +0100 Subject: [PATCH 4/4] Read still axis-only components at 5 Hz --- crates/simrig-gui/src/sampling.rs | 2 +- docs/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/simrig-gui/src/sampling.rs b/crates/simrig-gui/src/sampling.rs index 5c5d1f1..995c579 100644 --- a/crates/simrig-gui/src/sampling.rs +++ b/crates/simrig-gui/src/sampling.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use simrig_model::Value; /// Readings per second of a component with axes only, while it stays still. -pub const STILL_HZ: f64 = 10.0; +pub const STILL_HZ: f64 = 5.0; /// How long a component with axes only stays at the live rate after it last moved. const STILL_AFTER: Duration = Duration::from_secs(1); diff --git a/docs/architecture.md b/docs/architecture.md index 47aa31f..5045f22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -507,7 +507,7 @@ kernel_driver = "fanatec" # optional: the HID driver games need | app → plugin | `action/run` | standard actions: calibration, centering, test effect, stop force | | app → plugin | `output/write` | standard outputs: LEDs, display | | app → plugin | `input/read` | all supported inputs once | -| app → plugin | `input/subscribe` `input/unsubscribe` → `input/state` | live inputs when they change, at a capped rate; subscribing again changes it (the app reads components with axes only at 10 Hz while they stay still) | +| app → plugin | `input/subscribe` `input/unsubscribe` → `input/state` | live inputs when they change, at a capped rate; subscribing again changes it (the app reads components with axes only at 5 Hz while they stay still) | | plugin → app | `params/changed` | fresh description after a set or action (availability may have changed) | | app → plugin | `shutdown` | the app is closing | | plugin → app | `components/changed` | components of a device changed (rim swap), with their descriptions |