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
2,979 changes: 2,927 additions & 52 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ edition = "2021"

[dependencies]
bitflags = "2.13.2"
# 描画は glow (OpenGL) で足りる。既定の wgpu は依存が重く、この用途に利点がない。
# wayland と x11 は Windows では効かないが、Linux で cargo test と clippy を通すために要る。
eframe = { version = "0.36.2", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
# ui.rs は eframe を知らずに済ませる。eframe に触るのは app.rs と main.rs だけである。
egui = "0.36.2"

[target."cfg(windows)".dependencies]
hidapi = "2.6.7"
Expand Down
280 changes: 280 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
//! スレッドの起動と配線。
//!
//! スレッドは 2 本ある。入力スレッドは [`crate::hid_source`] のブロッキング read から
//! [`History`] までを回し、UI スレッドは eframe のループで [`History`] を読む。
//! 250Hz の書き込みと 60Hz の読み出しなので [`Mutex`] で足りる。ロックを持つのは
//! push と snapshot のコピーの間だけにする。

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use eframe::egui;

use crate::hid_source;
use crate::history::{History, Trial, DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US};
use crate::report_decode::decode;
use crate::timeline::{EventKind, InputEvent, Target, Timeline};
use crate::ui;

/// 左スティックを方向入力と見なす閾値。設計 spec の既定値。
const STICK_DEADZONE: f32 = 0.5;

/// 方向を切り替える角度の閾値。セクタの半分にすると、隣のセクタに入った時点で切り替わる。
const STICK_HYSTERESIS_DEG: f32 = 22.5;

/// 抜き差しの再試行間隔。
const RECONNECT_INTERVAL: Duration = Duration::from_secs(1);

/// UI を描き直す間隔。押しっぱなしの長さは入力が無くても伸びるので、待たずに描き直す。
const REPAINT_INTERVAL: Duration = Duration::from_millis(16);

/// 入力スレッドと UI スレッドが共有する状態。
#[derive(Debug)]
pub struct Shared {
history: Mutex<History>,
status: Mutex<ui::Status>,
}

impl Shared {
pub fn new() -> Self {
Self {
history: Mutex::new(History::new(DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US)),
status: Mutex::new(ui::Status::default()),
}
}

/// 表示に使う試行を複製する。何件読むかの判断は [`ui::recent_trials`] が持つ。
/// ロックはこの複製の間だけで、描画の間は持たない。
pub fn snapshot(&self, settings: &ui::Settings) -> Vec<Trial> {
ui::recent_trials(&self.lock_history(), settings)
}

pub fn status(&self) -> ui::Status {
self.lock_status().clone()
}

fn push(&self, events: &[InputEvent], now_us: u64) {
self.lock_history().push(events, now_us);
}

fn now_us(&self) -> u64 {
self.lock_history().now_us()
}

/// 毒された Mutex でも中身を取り出して続ける。入力スレッドが panic したときに
/// UI まで落とすと、直前まで溜めた履歴を読めなくなる。[`Self::lock_status`] も同じ。
fn lock_history(&self) -> std::sync::MutexGuard<'_, History> {
self.history.lock().unwrap_or_else(|err| err.into_inner())
}

fn lock_status(&self) -> std::sync::MutexGuard<'_, ui::Status> {
self.status.lock().unwrap_or_else(|err| err.into_inner())
}
}

impl Default for Shared {
fn default() -> Self {
Self::new()
}
}

/// eframe に渡すアプリ。生成した時点で入力スレッドが走り出す。
pub struct FrametapApp {
shared: Arc<Shared>,
settings: ui::Settings,
}

impl FrametapApp {
pub fn new(settings: ui::Settings) -> Self {
let shared = Arc::new(Shared::new());
let input = Arc::clone(&shared);
thread::spawn(move || run_input(&input));

Self { shared, settings }
}
}

impl eframe::App for FrametapApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let trials = self.shared.snapshot(&self.settings);
let status = self.shared.status();

// egui の慣習に合わせて引数を ui と呼ぶため、モジュールは絶対パスで書く。
crate::ui::show(ui, &trials, &status, &self.settings);

// 押しっぱなしの持続 F は入力が無くても伸びる。イベント待ちで止めない。
ui.ctx().request_repaint_after(REPAINT_INTERVAL);
}
}

/// 接続を張り直しながらレポートを読み続ける。戻らない。
///
/// 履歴は接続をまたいで残す。抜き差しの前に読んでいた試行が消えると、
/// 何が起きたかを確かめる手段がなくなる。
fn run_input(shared: &Shared) {
loop {
let reason = match hid_source::open() {
Ok(connection) => {
*shared.lock_status() = ui::Status::connected(connection.name().to_owned());
pump(shared, &connection)
}
Err(reason) => reason,
};

// 代入の文で guard を捨てる。次の sleep の間ロックを持つと、その 1 秒は UI が状態を読めない。
*shared.lock_status() = ui::Status::disconnected(reason);

thread::sleep(RECONNECT_INTERVAL);
}
}

/// 1 接続分のレポートを読み続ける。戻り値は読みが止まった理由。
fn pump(shared: &Shared, connection: &hid_source::Connection) -> String {
let device = connection.device();
let mut timeline = Timeline::new(device, STICK_DEADZONE, STICK_HYSTERESIS_DEG);

// [`Timeline`] が出す時刻は接続ごとに 0 から始まる。履歴の時刻は戻せないので、
// 前の接続の末尾から試行の区切り以上空けた位置に載せ直す。空けないと抜き差しの
// 前後が 1 試行に繋がる。
let base_us = shared.now_us() + DEFAULT_TRIAL_GAP_US;
let mut held: Vec<Target> = Vec::new();

loop {
let report = match connection.read() {
Ok(report) => report,
Err(reason) => {
// 抜かれた時点で押されていたものは、離しが届かない。ここで離しておかないと
// 進行中の試行が押しっぱなしのまま残り、次の試行と繋がる。
let now_us = base_us + timeline.now_us();
shared.push(&releases(&held, now_us), now_us);
return reason;
}
};

// 壊れたレポートは捨てる。補間すると存在しない F 数が出る。
let Some(state) = decode(device, report.bytes()) else {
continue;
};

let mut events = timeline.push(state, report.host_qpc_us());
for event in &mut events {
event.at_us += base_us;
}
track_held(&mut held, &events);

shared.push(&events, base_us + timeline.now_us());

let mut status = shared.lock_status();
status.scale_us_per_tick = Some(timeline.scale_us_per_tick());
status.scale_warning = timeline.scale_warning();
}
}

/// 押されているものを追う。[`History`] の中の状態は読めないので、ここで別に持つ。
fn track_held(held: &mut Vec<Target>, events: &[InputEvent]) {
for event in events {
match event.kind {
EventKind::Press => {
if !held.contains(&event.target) {
held.push(event.target);
}
}
EventKind::Release => held.retain(|target| *target != event.target),
}
}
}

fn releases(held: &[Target], at_us: u64) -> Vec<InputEvent> {
held.iter()
.map(|target| InputEvent {
kind: EventKind::Release,
target: *target,
at_us,
// 離しを作ったのはデバイスではなく切断である。時間差は信用できない。
gap_before: true,
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::report_decode::Buttons;

fn press(target: Target) -> InputEvent {
InputEvent {
kind: EventKind::Press,
target,
at_us: 0,
gap_before: false,
}
}

#[test]
fn track_held_follows_press_and_release() {
let mut held = Vec::new();
track_held(&mut held, &[press(Target::Button(Buttons::L1))]);
track_held(&mut held, &[press(Target::Button(Buttons::R1))]);
// 同じ入力の押下が重なっても増えない。
track_held(&mut held, &[press(Target::Button(Buttons::L1))]);
assert_eq!(
held,
vec![Target::Button(Buttons::L1), Target::Button(Buttons::R1)]
);

track_held(
&mut held,
&[InputEvent {
kind: EventKind::Release,
..press(Target::Button(Buttons::L1))
}],
);
assert_eq!(held, vec![Target::Button(Buttons::R1)]);
}

/// 切断で作る離しは、押されていたものを漏れなく閉じる。
#[test]
fn releases_close_every_held_target() {
let held = vec![Target::Button(Buttons::L1), Target::Button(Buttons::R1)];
let events = releases(&held, 1_234);

assert_eq!(events.len(), 2);
assert!(events
.iter()
.all(|event| event.kind == EventKind::Release && event.at_us == 1_234));
assert!(releases(&[], 0).is_empty());
}

/// 履歴は接続をまたいで残り、次の接続の入力は別の試行になる。
/// 実機を繋がずに、入力スレッドが履歴へ書く手順だけを再現して確かめる。
#[test]
fn a_reconnect_starts_a_new_trial_and_keeps_the_old_one() {
let shared = Shared::new();
let first_us = 1_000;
shared.push(&[press(Target::Button(Buttons::L1))], 0);
shared.push(
&[InputEvent {
kind: EventKind::Release,
at_us: first_us,
..press(Target::Button(Buttons::L1))
}],
first_us,
);

// 再接続。デバイス時刻は 0 に戻るので、履歴の末尾から区切り分だけ空けて載せ直す。
let base_us = shared.now_us() + DEFAULT_TRIAL_GAP_US;
shared.push(
&[InputEvent {
at_us: base_us,
..press(Target::Button(Buttons::R1))
}],
base_us,
);

let trials = shared.snapshot(&ui::Settings::default());
assert_eq!(trials.len(), 2);
assert_eq!(trials[0].origin_us, base_us);
assert_eq!(trials[1].origin_us, 0);
}
}
Loading
Loading