Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 7 additions & 1 deletion crates/simrig-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,16 @@ fn watch(host: &mut Host, component: &str, rate: f64, seconds: Option<f64>) -> 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")?;
}
Expand Down
35 changes: 24 additions & 11 deletions crates/simrig-gui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -134,7 +134,8 @@ impl App {
}
let theme = window.global::<ui::Theme>();
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);
Expand Down Expand Up @@ -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]));
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -739,10 +748,14 @@ impl App {
self.window().global::<ui::Theme>().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::<ui::Theme>().set_reduced_motion(on);
self.window()
.global::<ui::Theme>()
.set_reduced_motion(mode.reduced_motion());
self.send(Command::LiveRate(mode.live_rate()));
self.update_cards(|_| true);
}

// ------------------------------------------------------------------ toasts
Expand Down
44 changes: 30 additions & 14 deletions crates/simrig-gui/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -13,16 +13,18 @@ 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
/// only with the rig.
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,
Expand Down Expand Up @@ -225,10 +227,10 @@ struct Dirty {

fn run(options: HostOptions, rx: Receiver<Command>, deliver: impl Fn(Update)) {
let mut host = Host::start(&options);
let mut subscribed: HashSet<String> = 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<DeviceFindings>| {
let mut s = snapshot(host, drivers);
Expand All @@ -245,7 +247,8 @@ fn run(options: HostOptions, rx: Receiver<Command>, 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) => {
Expand All @@ -254,6 +257,12 @@ fn run(options: HostOptions, rx: Receiver<Command>, 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);
}
}
Expand All @@ -270,6 +279,9 @@ fn run(options: HostOptions, rx: Receiver<Command>, 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 } => {
Expand All @@ -290,8 +302,11 @@ fn run(options: HostOptions, rx: Receiver<Command>, 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 {
Expand All @@ -309,7 +324,7 @@ fn run(options: HostOptions, rx: Receiver<Command>, 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(
Expand Down Expand Up @@ -343,16 +358,15 @@ fn coalesce(batch: Vec<Command>) -> Vec<Command> {
}

/// Subscribes to the live inputs of every component not subscribed yet.
fn resubscribe(host: &mut Host, subscribed: &mut HashSet<String>) {
fn resubscribe(host: &mut Host, sampling: &mut Sampling) {
let addresses: Vec<String> = 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());
}
}
}
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions crates/simrig-gui/src/flows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 2 additions & 5 deletions crates/simrig-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod inputs;
mod procedure;
mod raster;
mod report;
mod sampling;
mod settings;
mod shot;
mod update;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading