diff --git a/frametap.toml.example b/frametap.toml.example new file mode 100644 index 0000000..573811d --- /dev/null +++ b/frametap.toml.example @@ -0,0 +1,14 @@ +# frametap の設定。実行ファイルと同じディレクトリに frametap.toml として置く。 +# 置かなくても既定値で動く。書いた値が受け付けられなければ、画面の上に警告が出る。 + +# F 換算の基準。対象のゲームのフレームレートに合わせる。 +fps = 60 + +# 試行を区切る無入力時間 (ms)。全部離してこの時間が過ぎたら、次の入力は別の試行になる。 +trial_gap_ms = 300 + +# 左スティックを方向入力と見なす閾値。中心からの正規化距離で、0 から 1 の間。 +stick_deadzone = 0.5 + +# 画面に積む試行の数。 +trials_shown = 5 diff --git a/src/app.rs b/src/app.rs index 093843c..1c770f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,15 +11,13 @@ use std::time::Duration; use eframe::egui; +use crate::config::{Config, Loaded}; use crate::hid_source; -use crate::history::{History, Trial, DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US}; +use crate::history::{History, Trial, DEFAULT_RETAIN_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; @@ -29,6 +27,19 @@ const RECONNECT_INTERVAL: Duration = Duration::from_secs(1); /// UI を描き直す間隔。押しっぱなしの長さは入力が無くても伸びるので、待たずに描き直す。 const REPAINT_INTERVAL: Duration = Duration::from_millis(16); +/// 設定の ms を µs に直す。桁溢れは上限で止める。設定ファイルの値を検算していないため。 +fn trial_gap_us(config: &Config) -> u64 { + config.trial_gap_ms.saturating_mul(1_000) +} + +/// 設定から表示の設定を取り出す。[`ui`] に [`Config`] を知らせないため、変換をここに置く。 +fn settings_from(config: &Config) -> ui::Settings { + ui::Settings { + fps: config.fps, + trials_shown: config.trials_shown, + } +} + /// 入力スレッドと UI スレッドが共有する状態。 #[derive(Debug)] pub struct Shared { @@ -37,9 +48,9 @@ pub struct Shared { } impl Shared { - pub fn new() -> Self { + pub fn new(config: &Config) -> Self { Self { - history: Mutex::new(History::new(DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US)), + history: Mutex::new(History::new(DEFAULT_RETAIN_US, trial_gap_us(config))), status: Mutex::new(ui::Status::default()), } } @@ -75,7 +86,7 @@ impl Shared { impl Default for Shared { fn default() -> Self { - Self::new() + Self::new(&Config::default()) } } @@ -83,15 +94,22 @@ impl Default for Shared { pub struct FrametapApp { shared: Arc, settings: ui::Settings, + /// 設定ファイルを読んだときの警告。画面に出したままにする。 + notices: Vec, } impl FrametapApp { - pub fn new(settings: ui::Settings) -> Self { - let shared = Arc::new(Shared::new()); + pub fn new(loaded: Loaded) -> Self { + let Loaded { config, warnings } = loaded; + let shared = Arc::new(Shared::new(&config)); let input = Arc::clone(&shared); - thread::spawn(move || run_input(&input)); + thread::spawn(move || run_input(&input, &config)); - Self { shared, settings } + Self { + shared, + settings: settings_from(&config), + notices: warnings, + } } } @@ -101,7 +119,7 @@ impl eframe::App for FrametapApp { let status = self.shared.status(); // egui の慣習に合わせて引数を ui と呼ぶため、モジュールは絶対パスで書く。 - crate::ui::show(ui, &trials, &status, &self.settings); + crate::ui::show(ui, &trials, &status, &self.settings, &self.notices); // 押しっぱなしの持続 F は入力が無くても伸びる。イベント待ちで止めない。 ui.ctx().request_repaint_after(REPAINT_INTERVAL); @@ -112,12 +130,12 @@ impl eframe::App for FrametapApp { /// /// 履歴は接続をまたいで残す。抜き差しの前に読んでいた試行が消えると、 /// 何が起きたかを確かめる手段がなくなる。 -fn run_input(shared: &Shared) { +fn run_input(shared: &Shared, config: &Config) { loop { let reason = match hid_source::open() { Ok(connection) => { *shared.lock_status() = ui::Status::connected(connection.name().to_owned()); - pump(shared, &connection) + pump(shared, &connection, config) } Err(reason) => reason, }; @@ -130,14 +148,14 @@ fn run_input(shared: &Shared) { } /// 1 接続分のレポートを読み続ける。戻り値は読みが止まった理由。 -fn pump(shared: &Shared, connection: &hid_source::Connection) -> String { +fn pump(shared: &Shared, connection: &hid_source::Connection, config: &Config) -> String { let device = connection.device(); - let mut timeline = Timeline::new(device, STICK_DEADZONE, STICK_HYSTERESIS_DEG); + let mut timeline = Timeline::new(device, config.stick_deadzone, STICK_HYSTERESIS_DEG); // [`Timeline`] が出す時刻は接続ごとに 0 から始まる。履歴の時刻は戻せないので、 // 前の接続の末尾から試行の区切り以上空けた位置に載せ直す。空けないと抜き差しの // 前後が 1 試行に繋がる。 - let base_us = shared.now_us() + DEFAULT_TRIAL_GAP_US; + let base_us = shared.now_us() + trial_gap_us(config); let mut held: Vec = Vec::new(); loop { @@ -246,11 +264,74 @@ mod tests { assert!(releases(&[], 0).is_empty()); } + fn release(target: Target, at_us: u64) -> InputEvent { + InputEvent { + kind: EventKind::Release, + at_us, + ..press(target) + } + } + + /// 押して離し、`gap_us` 空けてもう一度押す。区切りが効いたかを試行数で見る。 + fn trials_after_a_gap(config: &Config, gap_us: u64) -> usize { + let shared = Shared::new(config); + let first_release_us = 1_000; + + shared.push(&[press(Target::Button(Buttons::L1))], 0); + shared.push( + &[release(Target::Button(Buttons::L1), first_release_us)], + first_release_us, + ); + + let second_press_us = first_release_us + gap_us; + shared.push( + &[InputEvent { + at_us: second_press_us, + ..press(Target::Button(Buttons::R1)) + }], + second_press_us, + ); + + shared.snapshot(&ui::Settings::default()).len() + } + + /// 区切りの間隔が設定から届く。 + #[test] + fn a_shorter_configured_trial_gap_splits_the_trial() { + let config = Config { + trial_gap_ms: 50, + ..Config::default() + }; + + assert_eq!(trials_after_a_gap(&config, 60_000), 2); + } + + /// 同じ間隔でも既定の区切り (300ms) では割れない。設定が効いていることの対になる。 + #[test] + fn the_same_gap_does_not_split_under_the_default_configuration() { + assert_eq!(trials_after_a_gap(&Config::default(), 60_000), 1); + } + + /// 表示の設定が設定ファイルから届く。 + #[test] + fn the_display_settings_come_from_the_configuration() { + let config = Config { + fps: 30.0, + trials_shown: 12, + ..Config::default() + }; + + let settings = settings_from(&config); + + assert_eq!(settings.fps, 30.0); + assert_eq!(settings.trials_shown, 12); + } + /// 履歴は接続をまたいで残り、次の接続の入力は別の試行になる。 /// 実機を繋がずに、入力スレッドが履歴へ書く手順だけを再現して確かめる。 #[test] fn a_reconnect_starts_a_new_trial_and_keeps_the_old_one() { - let shared = Shared::new(); + let shared = Shared::new(&Config::default()); let first_us = 1_000; shared.push(&[press(Target::Button(Buttons::L1))], 0); shared.push( @@ -263,7 +344,7 @@ mod tests { ); // 再接続。デバイス時刻は 0 に戻るので、履歴の末尾から区切り分だけ空けて載せ直す。 - let base_us = shared.now_us() + DEFAULT_TRIAL_GAP_US; + let base_us = shared.now_us() + trial_gap_us(&Config::default()); shared.push( &[InputEvent { at_us: base_us, diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..50916ff --- /dev/null +++ b/src/config.rs @@ -0,0 +1,345 @@ +//! 実行ファイルと同じディレクトリに置く `frametap.toml` の読み込み。 +//! +//! 受け付けるのは数値 4 つだけで、section も配列も文字列も扱わない。汎用の TOML +//! パーサを入れずに自前で読むのは、この 4 行のためにパーサの依存を増やさないため。 +//! +//! 読めなかった行と受け付けられない値は既定値のままにして、警告を [`Loaded::warnings`] +//! に積む。黙って既定値に落とすと、設定を書いたのに効いていない状態を画面から +//! 区別できない。配って使ってもらう道具なので、効かなかったことが見える側を選ぶ。 + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// 読み込むファイルの名前。 +pub const FILE_NAME: &str = "frametap.toml"; + +/// F 換算の基準の既定値。 +pub const DEFAULT_FPS: f64 = 60.0; + +/// 試行を区切る無入力時間の既定値。 +pub const DEFAULT_TRIAL_GAP_MS: u64 = 300; + +/// 左スティックを方向入力と見なす閾値の既定値。 +pub const DEFAULT_STICK_DEADZONE: f32 = 0.5; + +/// 縦リストに積む試行数の既定値。 +pub const DEFAULT_TRIALS_SHOWN: usize = 5; + +/// 設定の値。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Config { + /// F 換算の基準。 + pub fps: f64, + /// 試行を区切る無入力時間。 + pub trial_gap_ms: u64, + /// 左スティックを方向入力と見なす閾値。 + pub stick_deadzone: f32, + /// 縦リストに積む試行数。 + pub trials_shown: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + fps: DEFAULT_FPS, + trial_gap_ms: DEFAULT_TRIAL_GAP_MS, + stick_deadzone: DEFAULT_STICK_DEADZONE, + trials_shown: DEFAULT_TRIALS_SHOWN, + } + } +} + +/// 読み込みの結果。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Loaded { + pub config: Config, + /// 読めなかった行と受け付けられなかった値の説明。画面に出す。 + pub warnings: Vec, +} + +/// 実行ファイルと同じディレクトリの [`FILE_NAME`] を読む。 +/// +/// ファイルが無いときは既定値だけを返し、警告も出さない。置かずに使うのが普通の状態である。 +pub fn load() -> Loaded { + read(&file_path(executable_dir())) +} + +/// 探しに行くファイルの位置。実行ファイルの場所が分からなければ現在のディレクトリを使う。 +pub fn file_path(exe_dir: Option) -> PathBuf { + match exe_dir { + Some(dir) => dir.join(FILE_NAME), + None => PathBuf::from(FILE_NAME), + } +} + +/// 指定した位置のファイルを読む。無ければ既定値だけを返す。 +pub fn read(path: &Path) -> Loaded { + match fs::read_to_string(path) { + Ok(text) => parse(&text), + Err(err) if err.kind() == io::ErrorKind::NotFound => Loaded::default(), + // 置いてあるのに読めないのは権限か壊れた文字である。既定値で起動は続けるが黙らない。 + Err(err) => Loaded { + config: Config::default(), + warnings: vec![format!("{} を読めない: {err}", path.display())], + }, + } +} + +/// 1 行 1 キーの `key = value` を読む。 +pub fn parse(text: &str) -> Loaded { + let mut config = Config::default(); + let mut warnings = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + + for (index, raw) in text.lines().enumerate() { + let number = index + 1; + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + + // section は読まない。黙って飛ばすと、中に書いたキーが効かない理由が分からない。 + if line.starts_with('[') { + warnings.push(format!( + "{number} 行目: section を読まない。`{line}` を無視した" + )); + continue; + } + + let Some((key, value)) = line.split_once('=') else { + warnings.push(format!("{number} 行目: `=` が無い。`{line}` を無視した")); + continue; + }; + let (key, value) = (key.trim(), value.trim()); + + if seen.contains(&key) { + warnings.push(format!( + "{number} 行目: `{key}` が 2 回ある。後ろの値を使う" + )); + } + seen.push(key); + + match key { + "fps" => match value.parse::() { + Ok(fps) if fps > 0.0 => config.fps = fps, + Ok(_) => warnings.push(out_of_range(number, key, value, &DEFAULT_FPS.to_string())), + Err(_) => warnings.push(not_a_number(number, key, value, &DEFAULT_FPS.to_string())), + }, + "trial_gap_ms" => match value.parse::() { + Ok(ms) => config.trial_gap_ms = ms, + Err(_) => warnings.push(not_a_number( + number, + key, + value, + &DEFAULT_TRIAL_GAP_MS.to_string(), + )), + }, + "stick_deadzone" => match value.parse::() { + Ok(deadzone) if (0.0..=1.0).contains(&deadzone) => config.stick_deadzone = deadzone, + Ok(_) => warnings.push(out_of_range( + number, + key, + value, + &DEFAULT_STICK_DEADZONE.to_string(), + )), + Err(_) => warnings.push(not_a_number( + number, + key, + value, + &DEFAULT_STICK_DEADZONE.to_string(), + )), + }, + "trials_shown" => match value.parse::() { + Ok(shown) if shown > 0 => config.trials_shown = shown, + Ok(_) => warnings.push(out_of_range( + number, + key, + value, + &DEFAULT_TRIALS_SHOWN.to_string(), + )), + Err(_) => warnings.push(not_a_number( + number, + key, + value, + &DEFAULT_TRIALS_SHOWN.to_string(), + )), + }, + _ => warnings.push(format!("{number} 行目: `{key}` は知らないキーである")), + } + } + + Loaded { config, warnings } +} + +/// 実行ファイルの置き場所。取れなければ `None`。 +fn executable_dir() -> Option { + Some(std::env::current_exe().ok()?.parent()?.to_path_buf()) +} + +/// `#` から行末までを落とす。値に文字列を取らないので、引用符の中を考えなくてよい。 +fn strip_comment(line: &str) -> &str { + match line.split_once('#') { + Some((before, _)) => before, + None => line, + } +} + +fn not_a_number(number: usize, key: &str, value: &str, fallback: &str) -> String { + format!("{number} 行目: `{key}` の `{value}` を数として読めない。既定の {fallback} を使う") +} + +fn out_of_range(number: usize, key: &str, value: &str, fallback: &str) -> String { + format!("{number} 行目: `{key}` の `{value}` は受け付けない。既定の {fallback} を使う") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_text_gives_the_defaults() { + let loaded = parse(""); + + assert_eq!(loaded.config, Config::default()); + assert!(loaded.warnings.is_empty()); + } + + #[test] + fn every_key_is_read() { + let loaded = + parse("fps = 30\ntrial_gap_ms = 500\nstick_deadzone = 0.25\ntrials_shown = 12\n"); + + assert_eq!(loaded.config.fps, 30.0); + assert_eq!(loaded.config.trial_gap_ms, 500); + assert_eq!(loaded.config.stick_deadzone, 0.25); + assert_eq!(loaded.config.trials_shown, 12); + assert!(loaded.warnings.is_empty()); + } + + #[test] + fn blank_lines_and_comments_are_skipped() { + let loaded = parse("# 見出し\n\n \nfps = 30\n # 行の途中に置いた註\n"); + + assert_eq!(loaded.config.fps, 30.0); + assert!(loaded.warnings.is_empty()); + } + + #[test] + fn a_comment_after_a_value_is_cut() { + let loaded = parse("fps = 30 # 対象のゲームに合わせる\n"); + + assert_eq!(loaded.config.fps, 30.0); + assert!(loaded.warnings.is_empty()); + } + + #[test] + fn an_unknown_key_keeps_the_defaults_and_warns() { + let loaded = parse("fsp = 30\n"); + + assert_eq!(loaded.config, Config::default()); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("fsp")); + } + + #[test] + fn a_value_that_is_not_a_number_keeps_the_default_and_warns() { + let loaded = parse("fps = ろくじゅう\n"); + + assert_eq!(loaded.config.fps, DEFAULT_FPS); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("fps")); + } + + #[test] + fn a_line_without_an_equals_sign_warns() { + let loaded = parse("fps 30\n"); + + assert_eq!(loaded.config, Config::default()); + assert_eq!(loaded.warnings.len(), 1); + } + + #[test] + fn a_section_header_warns_instead_of_being_ignored() { + let loaded = parse("[display]\nfps = 30\n"); + + assert_eq!(loaded.config.fps, 30.0); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("[display]")); + } + + /// 0 以下の fps は 1F の長さが決まらない。 + #[test] + fn a_non_positive_fps_keeps_the_default_and_warns() { + for text in ["fps = 0\n", "fps = -30\n"] { + let loaded = parse(text); + + assert_eq!(loaded.config.fps, DEFAULT_FPS, "{text}"); + assert_eq!(loaded.warnings.len(), 1, "{text}"); + } + } + + /// 閾値は正規化した距離との比較なので 0 から 1 の外に意味が無い。 + #[test] + fn a_stick_deadzone_outside_zero_to_one_keeps_the_default_and_warns() { + for text in ["stick_deadzone = -0.1\n", "stick_deadzone = 1.5\n"] { + let loaded = parse(text); + + assert_eq!( + loaded.config.stick_deadzone, DEFAULT_STICK_DEADZONE, + "{text}" + ); + assert_eq!(loaded.warnings.len(), 1, "{text}"); + } + } + + /// 0 件だと画面に何も出ない。設定として通さない。 + #[test] + fn a_zero_trials_shown_keeps_the_default_and_warns() { + let loaded = parse("trials_shown = 0\n"); + + assert_eq!(loaded.config.trials_shown, DEFAULT_TRIALS_SHOWN); + assert_eq!(loaded.warnings.len(), 1); + } + + /// 同じキーを 2 回書いたときは後ろが勝つ。気づかずに前の行を残した場合に備えて警告も出す。 + #[test] + fn a_repeated_key_takes_the_last_value_and_warns() { + let loaded = parse("fps = 30\nfps = 120\n"); + + assert_eq!(loaded.config.fps, 120.0); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("fps")); + } + + #[test] + fn several_bad_lines_each_get_a_warning() { + let loaded = parse("fsp = 30\nfps = はやい\ntrials_shown = 0\n"); + + assert_eq!(loaded.config, Config::default()); + assert_eq!(loaded.warnings.len(), 3); + } + + #[test] + fn the_file_sits_next_to_the_executable() { + let path = file_path(Some(PathBuf::from("/opt/frametap"))); + + assert_eq!(path, PathBuf::from("/opt/frametap").join(FILE_NAME)); + } + + /// 実行ファイルの位置が取れなくても、探す先を決めて起動を続ける。 + #[test] + fn without_an_executable_directory_the_file_is_looked_up_in_the_current_directory() { + assert_eq!(file_path(None), PathBuf::from(FILE_NAME)); + } + + /// 置いていないのが普通の状態なので、無いことを警告にしない。 + #[test] + fn a_missing_file_gives_the_defaults_without_a_warning() { + let missing = PathBuf::from("/frametap-no-such-directory").join(FILE_NAME); + + let loaded = read(&missing); + + assert_eq!(loaded, Loaded::default()); + } +} diff --git a/src/lib.rs b/src/lib.rs index e9ed526..789fe91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,10 @@ //! 他のモジュールは実機なしでテストできる。 pub mod app; +pub mod config; pub mod hid_source; pub mod history; +pub mod panic_report; pub mod report_decode; pub mod timeline; pub mod ui; diff --git a/src/main.rs b/src/main.rs index a3f91b1..1ccd3e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,24 @@ //! ウィンドウを開いて入力スレッドを起動する。 //! //! サブモニタに置いてゲームと並走させるため、always-on-top で開く。 +//! +//! release build では console を出さない。常時置いておく道具なので、起動のたびに +//! 黒い窓が並ぶと邪魔になる。debug build では残す。開発中は標準出力を読む。 +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::egui::ViewportBuilder; -use frametap::{app::FrametapApp, ui}; +use frametap::{app::FrametapApp, config, panic_report}; /// 初期のウィンドウサイズ。名前の列と F の 2 列が入り、試行が数件見える幅と高さ。 const WINDOW_SIZE: [f32; 2] = [420.0, 640.0]; fn main() -> eframe::Result<()> { + // console が無い release build では、既定の hook が書いた内容がどこにも出ない。 + // 設定の読み込みより先に差し込む。読み込みの中で落ちても報告を残すため。 + panic_report::install(); + + let loaded = config::load(); + let options = eframe::NativeOptions { viewport: ViewportBuilder::default() .with_title("frametap") @@ -20,6 +30,6 @@ fn main() -> eframe::Result<()> { eframe::run_native( "frametap", options, - Box::new(|_cc| Ok(Box::new(FrametapApp::new(ui::Settings::default())))), + Box::new(move |_cc| Ok(Box::new(FrametapApp::new(loaded)))), ) } diff --git a/src/panic_report.rs b/src/panic_report.rs new file mode 100644 index 0000000..b3a841c --- /dev/null +++ b/src/panic_report.rs @@ -0,0 +1,168 @@ +//! panic の内容をファイルに残す。 +//! +//! release build では [`main`] に `windows_subsystem = "windows"` が付いて console が +//! 無くなるので、既定の hook が書いた内容はどこにも出ない。実機で動かすのは Windows と +//! DualSense を持つ人なので、落ちた理由を手元に残せないと「落ちた」以外を返せない。 +//! +//! 追記にするのは、再現の回数と間隔も判断の材料になるため。上書きすると最後の 1 回しか残らない。 +//! +//! [`main`]: https://doc.rust-lang.org/reference/runtime.html + +use std::backtrace::Backtrace; +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::panic; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 書き出すファイルの名前。 +pub const FILE_NAME: &str = "frametap-panic.txt"; + +/// 位置が取れなかったときに書く文字。 +pub const UNKNOWN_LOCATION: &str = "位置不明"; + +/// panic の hook を差し込む。既定の hook は残す。 +/// +/// 既定の hook を外さないのは、debug build では console が生きているため。開発中に +/// panic が標準エラー出力から消えると、ファイルを開き直す手間が毎回かかる。 +pub fn install() { + let previous = panic::take_hook(); + let primary = file_path(executable_dir()); + let fallback = file_path(None); + + panic::set_hook(Box::new(move |info| { + let report = format_report( + secs_since_epoch(), + info.payload_as_str().unwrap_or("内容不明"), + info.location().map(ToString::to_string).as_deref(), + &Backtrace::force_capture().to_string(), + ); + + // 実行ファイルの隣が書けないことがある。Program Files の下に置かれた場合や、 + // 読み取り専用の媒体から起動した場合になる。書けないなら一時ディレクトリに回す。 + if append(&primary, &report).is_err() { + let _ = append(&fallback, &report); + } + + previous(info); + })); +} + +/// 書き出す先。実行ファイルの隣を使い、そこが分からなければ一時ディレクトリに置く。 +pub fn file_path(exe_dir: Option) -> PathBuf { + match exe_dir { + Some(dir) => dir.join(FILE_NAME), + None => std::env::temp_dir().join(FILE_NAME), + } +} + +/// ファイルに 1 件追記する。 +pub fn append(path: &Path, body: &str) -> io::Result<()> { + OpenOptions::new() + .create(true) + .append(true) + .open(path)? + .write_all(body.as_bytes()) +} + +/// 書き出す 1 件分の文字列。`secs_since_epoch` は起きた時刻。 +pub fn format_report( + secs_since_epoch: u64, + message: &str, + location: Option<&str>, + backtrace: &str, +) -> String { + format!( + "---- panic ----\n\ + 時刻: {secs_since_epoch} (UNIX epoch 秒)\n\ + 位置: {}\n\ + 内容: {message}\n\ + backtrace:\n{backtrace}\n\n", + location.unwrap_or(UNKNOWN_LOCATION) + ) +} + +/// 実行ファイルの置き場所。取れなければ `None`。 +fn executable_dir() -> Option { + Some(std::env::current_exe().ok()?.parent()?.to_path_buf()) +} + +/// 時刻を epoch 秒で読む。書式を整えるために依存を増やさない。並べ替えと間隔だけが要る。 +fn secs_since_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_report_carries_the_message_the_location_and_the_backtrace() { + let report = format_report( + 1_774_000_000, + "読めない値", + Some("src/app.rs:42:9"), + "0: frametap::main", + ); + + assert!(report.contains("読めない値"), "{report}"); + assert!(report.contains("src/app.rs:42:9"), "{report}"); + assert!(report.contains("0: frametap::main"), "{report}"); + } + + /// 追記なので、どの行がいつの panic かを時刻で分ける。回数と間隔も判断の材料になる。 + #[test] + fn a_report_carries_the_time_it_happened() { + let report = format_report(1_774_000_000, "読めない値", None, ""); + + assert!(report.contains("1774000000"), "{report}"); + } + + /// 位置が取れないのは panic の作り方によるので、報告を落とさず穴だけを示す。 + #[test] + fn a_report_without_a_location_says_so() { + let report = format_report(0, "読めない値", None, ""); + + assert!(report.contains(UNKNOWN_LOCATION), "{report}"); + } + + #[test] + fn the_file_sits_next_to_the_executable() { + let path = file_path(Some(PathBuf::from("/opt/frametap"))); + + assert_eq!(path, PathBuf::from("/opt/frametap").join(FILE_NAME)); + } + + /// 実行ファイルの位置が取れなくても書ける先を返す。ここで諦めると報告が残らない。 + #[test] + fn without_an_executable_directory_the_file_goes_to_the_temporary_directory() { + assert_eq!(file_path(None), std::env::temp_dir().join(FILE_NAME)); + } + + /// 2 回落ちたら 2 件残る。上書きすると再現の間隔が分からない。 + #[test] + fn a_second_report_is_appended() { + let path = std::env::temp_dir().join(format!("frametap-append-{}.txt", std::process::id())); + let _ = std::fs::remove_file(&path); + + append(&path, "1 回目").expect("1 回目を書けない"); + append(&path, "2 回目").expect("2 回目を書けない"); + + let written = std::fs::read_to_string(&path).expect("読めない"); + std::fs::remove_file(&path).expect("消せない"); + + assert!(written.contains("1 回目"), "{written}"); + assert!(written.contains("2 回目"), "{written}"); + } + + /// 書けない先だったことを呼び出し側が知れる。panic の中なので、ここから先は諦める。 + #[test] + fn appending_to_an_unreachable_path_fails() { + let unreachable = PathBuf::from("/frametap-no-such-directory").join(FILE_NAME); + + assert!(append(&unreachable, "本文").is_err()); + } +} diff --git a/src/ui.rs b/src/ui.rs index a9f84b5..4749b0d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -165,8 +165,14 @@ pub fn recent_trials(history: &History, settings: &Settings) -> Vec { /// ヘッダと縦リストを描く。`trials` は [`recent_trials`] の戻り値、 /// つまり新しい順に並んだ試行である。 -pub fn show(ui: &mut Ui, trials: &[Trial], status: &Status, settings: &Settings) { - show_header(ui, status, settings); +pub fn show( + ui: &mut Ui, + trials: &[Trial], + status: &Status, + settings: &Settings, + notices: &[String], +) { + show_header(ui, status, settings, notices); ui.separator(); // 見出しはスクロール領域の外に置く。リストが流れても列の意味が画面から消えない。 @@ -191,7 +197,7 @@ pub fn show(ui: &mut Ui, trials: &[Trial], status: &Status, settings: &Settings) /// /// 但し書きは条件付きで隠さない。1F ずれを読んでいる最中に、表示の幅が /// どこから来ているかを思い出せる状態にしておく。 -fn show_header(ui: &mut Ui, status: &Status, settings: &Settings) { +fn show_header(ui: &mut Ui, status: &Status, settings: &Settings, notices: &[String]) { match &status.device_name { Some(name) => ui.label(format!("接続: {name}")), None => ui.label( @@ -221,6 +227,11 @@ fn show_header(ui: &mut Ui, status: &Status, settings: &Settings) { "{:.0}fps の独自グリッドで数えるので、ゲーム内のフレーム数とは最大 1F ずれる", settings.fps.max(MIN_FPS) )); + + // 設定が効かなかったことは、隠すと画面から区別できない。出したままにする。 + for notice in notices { + ui.label(RichText::new(notice).color(Color32::LIGHT_RED)); + } } /// どの欄が何かを示す見出し。行と同じ割り付けを使うので、列とずれない。 diff --git a/tests/ui.rs b/tests/ui.rs index b8fc815..43c7d5a 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -229,7 +229,11 @@ fn show_runs_without_a_window() { ..Status::connected("DualSense".to_owned()) }; + // 設定の警告は接続の状態と無関係に出る。空の場合と出す場合の両方を通す。 + let notices = vec!["1 行目: `fsp` は知らないキーである".to_owned()]; for status in [connected, Status::default()] { - egui::__run_test_ui(|ui| show(ui, &trials, &status, &Settings::default())); + for notices in [[].as_slice(), notices.as_slice()] { + egui::__run_test_ui(|ui| show(ui, &trials, &status, &Settings::default(), notices)); + } } }