From a53ddb74009493b7598f56a3189b7408e5b06745 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 27 Sep 2026 18:01:15 +0900 Subject: [PATCH] fix(ui): align the columns and drop the released rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 つ直した。 画面に出る文字を英語にした。egui の既定フォントは Hack と Ubuntu-Light で、 日本語のグリフを 1 文字も持たない。実機では接続の表示も見出しも但し書きも 豆腐になっていた。UI に出るのは ui だけでなく timeline の警告と hid_source の エラーと config の警告も含むので、そちらも英語にした。 列の割り付けを egui に任せるのをやめた。allocate_ui_with_layout は要求した幅では なく実際に使った矩形のぶんだけ cursor を進めるので、名前の欄に固定幅を渡しても 同時押しの行で F の列が右にずれていた。等幅の 1 行に組んで桁を揃える。 離した区間の行を既定で出さないようにした。その行は持続 F しか持たず、読む行数 だけが倍になる。入力と入力の間合いは押下 F の差で読める。frametap.toml の show_released で戻せる。 方向名を numpad 表記にした。矢印は化けていなかったが、等幅の幅が保証されない。 Co-Authored-By: Claude Opus 5 (1M context) --- frametap.toml.example | 5 ++ src/app.rs | 1 + src/config.rs | 57 ++++++++++++++++++++--- src/hid_source.rs | 16 +++---- src/timeline.rs | 6 +-- src/ui.rs | 104 ++++++++++++++++++++---------------------- tests/ui.rs | 92 +++++++++++++++++++++++++++++++++---- 7 files changed, 199 insertions(+), 82 deletions(-) diff --git a/frametap.toml.example b/frametap.toml.example index 573811d..df4b871 100644 --- a/frametap.toml.example +++ b/frametap.toml.example @@ -12,3 +12,8 @@ stick_deadzone = 0.5 # 画面に積む試行の数。 trials_shown = 5 + +# 何も押していない区間を行として出すか。既定は出さない。 +# 出さないのは、その行が持続 F しか持たず、読む行数だけが倍になるため。 +# 入力と入力の間合いは、押下 F の差で読める。 +show_released = false diff --git a/src/app.rs b/src/app.rs index 1c770f5..2246c16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,6 +37,7 @@ fn settings_from(config: &Config) -> ui::Settings { ui::Settings { fps: config.fps, trials_shown: config.trials_shown, + show_released: config.show_released, } } diff --git a/src/config.rs b/src/config.rs index 50916ff..1c7244f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,10 @@ pub const DEFAULT_STICK_DEADZONE: f32 = 0.5; /// 縦リストに積む試行数の既定値。 pub const DEFAULT_TRIALS_SHOWN: usize = 5; +/// 離した区間を行にするかの既定値。既定で出さないのは、その行が持続 F しか持たず、 +/// 読む行数だけが倍になるため。間合いは押下 F の差で読める。 +pub const DEFAULT_SHOW_RELEASED: bool = false; + /// 設定の値。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Config { @@ -37,6 +41,8 @@ pub struct Config { pub stick_deadzone: f32, /// 縦リストに積む試行数。 pub trials_shown: usize, + /// 何も押していない区間を行にするか。 + pub show_released: bool, } impl Default for Config { @@ -46,6 +52,7 @@ impl Default for Config { trial_gap_ms: DEFAULT_TRIAL_GAP_MS, stick_deadzone: DEFAULT_STICK_DEADZONE, trials_shown: DEFAULT_TRIALS_SHOWN, + show_released: DEFAULT_SHOW_RELEASED, } } } @@ -81,7 +88,7 @@ pub fn read(path: &Path) -> Loaded { // 置いてあるのに読めないのは権限か壊れた文字である。既定値で起動は続けるが黙らない。 Err(err) => Loaded { config: Config::default(), - warnings: vec![format!("{} を読めない: {err}", path.display())], + warnings: vec![format!("cannot read {}: {err}", path.display())], }, } } @@ -102,20 +109,20 @@ pub fn parse(text: &str) -> Loaded { // section は読まない。黙って飛ばすと、中に書いたキーが効かない理由が分からない。 if line.starts_with('[') { warnings.push(format!( - "{number} 行目: section を読まない。`{line}` を無視した" + "line {number}: sections are not read, ignored `{line}`" )); continue; } let Some((key, value)) = line.split_once('=') else { - warnings.push(format!("{number} 行目: `=` が無い。`{line}` を無視した")); + warnings.push(format!("line {number}: no `=`, ignored `{line}`")); continue; }; let (key, value) = (key.trim(), value.trim()); if seen.contains(&key) { warnings.push(format!( - "{number} 行目: `{key}` が 2 回ある。後ろの値を使う" + "line {number}: `{key}` appears twice, using the last value" )); } seen.push(key); @@ -165,7 +172,17 @@ pub fn parse(text: &str) -> Loaded { &DEFAULT_TRIALS_SHOWN.to_string(), )), }, - _ => warnings.push(format!("{number} 行目: `{key}` は知らないキーである")), + "show_released" => match value { + "true" => config.show_released = true, + "false" => config.show_released = false, + _ => warnings.push(not_a_boolean( + number, + key, + value, + &DEFAULT_SHOW_RELEASED.to_string(), + )), + }, + _ => warnings.push(format!("line {number}: unknown key `{key}`")), } } @@ -186,11 +203,15 @@ fn strip_comment(line: &str) -> &str { } fn not_a_number(number: usize, key: &str, value: &str, fallback: &str) -> String { - format!("{number} 行目: `{key}` の `{value}` を数として読めない。既定の {fallback} を使う") + format!("line {number}: `{key}` = `{value}` is not a number, using default {fallback}") +} + +fn not_a_boolean(number: usize, key: &str, value: &str, fallback: &str) -> String { + format!("line {number}: `{key}` = `{value}` is not true or false, using default {fallback}") } fn out_of_range(number: usize, key: &str, value: &str, fallback: &str) -> String { - format!("{number} 行目: `{key}` の `{value}` は受け付けない。既定の {fallback} を使う") + format!("line {number}: `{key}` = `{value}` is out of range, using default {fallback}") } #[cfg(test)] @@ -293,6 +314,28 @@ mod tests { } } + #[test] + fn show_released_reads_true_and_false() { + assert!(parse("show_released = true\n").config.show_released); + assert!(!parse("show_released = false\n").config.show_released); + assert!(parse("show_released = true\n").warnings.is_empty()); + } + + /// 既定は非表示。離した区間の行は持続 F しか持たず、読む列を増やすだけになる。 + #[test] + fn show_released_defaults_to_false() { + assert!(!Config::default().show_released); + } + + #[test] + fn a_show_released_that_is_not_a_boolean_keeps_the_default_and_warns() { + let loaded = parse("show_released = yes\n"); + + assert!(!loaded.config.show_released); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("show_released")); + } + /// 0 件だと画面に何も出ない。設定として通さない。 #[test] fn a_zero_trials_shown_keeps_the_default_and_warns() { diff --git a/src/hid_source.rs b/src/hid_source.rs index 006dff1..321f361 100644 --- a/src/hid_source.rs +++ b/src/hid_source.rs @@ -78,7 +78,7 @@ mod platform { /// 最初に見つかった対象デバイスを読み取り専用で開く。 pub fn open() -> Result { - let api = HidApi::new().map_err(|err| format!("HidApi の初期化に失敗した: {err}"))?; + let api = HidApi::new().map_err(|err| format!("failed to initialize HidApi: {err}"))?; let (product_id, name, kind) = api .device_list() @@ -89,11 +89,11 @@ mod platform { .find(|(product_id, _, _)| *product_id == info.product_id()) .copied() }) - .ok_or_else(|| format!("対象デバイスが見つからない (VID {SONY_VENDOR_ID:#06X})"))?; + .ok_or_else(|| format!("no target device found (VID {SONY_VENDOR_ID:#06X})"))?; let device = api .open(SONY_VENDOR_ID, product_id) - .map_err(|err| format!("{name} を開けない: {err}"))?; + .map_err(|err| format!("cannot open {name}: {err}"))?; Ok(Connection { device, @@ -120,7 +120,7 @@ mod platform { let len = self .device .read_timeout(&mut buffer, BLOCK_UNTIL_REPORT) - .map_err(|err| format!("{} の読み取りに失敗した: {err}", self.name))?; + .map_err(|err| format!("read failed on {}: {err}", self.name))?; Ok(Report { buffer, @@ -137,7 +137,7 @@ mod platform { let mut ticks = 0i64; // SAFETY: ticks はスタック上の有効な i64。 unsafe { QueryPerformanceCounter(&mut ticks) } - .map_err(|err| format!("QueryPerformanceCounter に失敗した: {err}"))?; + .map_err(|err| format!("QueryPerformanceCounter failed: {err}"))?; Ok((i128::from(ticks) * 1_000_000 / i128::from(frequency)) as u64) } @@ -150,9 +150,9 @@ mod platform { let mut frequency = 0i64; // SAFETY: frequency はスタック上の有効な i64。 unsafe { QueryPerformanceFrequency(&mut frequency) } - .map_err(|err| format!("QueryPerformanceFrequency に失敗した: {err}"))?; + .map_err(|err| format!("QueryPerformanceFrequency failed: {err}"))?; if frequency <= 0 { - return Err(format!("QPC の周波数が {frequency} で使えない")); + return Err(format!("unusable QPC frequency: {frequency}")); } Ok(*QPC_FREQUENCY.get_or_init(|| frequency)) @@ -168,7 +168,7 @@ mod platform { pub enum Connection {} pub fn open() -> Result { - Err("HID の読み取りは Windows でのみ動く".to_owned()) + Err("HID reading works on Windows only".to_owned()) } impl Connection { diff --git a/src/timeline.rs b/src/timeline.rs index c16fa0c..5a88c6c 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -90,10 +90,10 @@ const STICK_DIRECTIONS: [Direction; 8] = [ ]; /// 十字キーの方向名。並びは [`direction_index`] に合わせる。 -const DPAD_NAMES: [&str; 8] = ["D↑", "D↗", "D→", "D↘", "D↓", "D↙", "D←", "D↖"]; +const DPAD_NAMES: [&str; 8] = ["DP8", "DP9", "DP6", "DP3", "DP2", "DP1", "DP4", "DP7"]; /// 左スティックの方向名。並びは [`direction_index`] に合わせる。 -const STICK_NAMES: [&str; 8] = ["L↑", "L↗", "L→", "L↘", "L↓", "L↙", "L←", "L↖"]; +const STICK_NAMES: [&str; 8] = ["LS8", "LS9", "LS6", "LS3", "LS2", "LS1", "LS4", "LS7"]; /// ボタン名。単一ビットの値だけを引ける。 const BUTTON_NAMES: [(Buttons, &str); 14] = [ @@ -267,7 +267,7 @@ impl Timeline { let deviation = (estimated - nominal).abs() / nominal; if deviation >= SCALE_WARNING_RATIO { self.scale_warning = Some(format!( - "デバイス時刻の単位が公称値から {:.0}% 外れている (推定 {estimated:.3}µs/tick、公称 {nominal:.2}µs/tick)", + "device clock unit is {:.0}% off nominal (estimated {estimated:.3} us/tick, nominal {nominal:.2} us/tick)", deviation * 100.0 )); } diff --git a/src/ui.rs b/src/ui.rs index 4749b0d..41ed1fc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,7 @@ //! //! [`History`]: crate::history::History -use egui::{Align, Color32, Frame, Layout, RichText, ScrollArea, Ui, Vec2}; +use egui::{Color32, Frame, RichText, ScrollArea, Ui}; use crate::history::{History, Trial}; @@ -20,6 +20,9 @@ pub const DEFAULT_FPS: f64 = 60.0; /// 縦リストに積む試行数の既定値。 pub const DEFAULT_TRIALS_SHOWN: usize = 5; +/// 離した区間を行にするかの既定値。 +pub const DEFAULT_SHOW_RELEASED: bool = false; + /// 持続 F をそのまま出す上限。これを超えたら [`HOLD_OVERFLOW_TEXT`] にする。 /// 押しっぱなしの行が桁を増やすと、右端の列幅が動いて他の行の数字が読みにくくなる。 const HOLD_FRAMES_CAP: f64 = 99.0; @@ -33,17 +36,20 @@ const BLANK_TEXT: &str = ""; /// USB 接続のレポート間隔。押下 F の量子化誤差はこの幅に収まる。 const USB_REPORT_INTERVAL_US: f64 = 4_000.0; -/// F の列の幅。3 桁と小数第 1 位が入る。 -const FRAMES_COLUMN_WIDTH: f32 = 52.0; +/// 名前の欄の文字数。同時押し 4 つ (`L1 R1 DP8 Cross` で 15 文字) が収まる。 +const NAMES_WIDTH: usize = 20; + +/// F の欄の文字数。`99.0` と見出しの `press` が収まる。 +const FRAMES_WIDTH: usize = 6; /// 名前の欄の見出し。 -const NAMES_HEADER: &str = "入力"; +const NAMES_HEADER: &str = "input"; /// 押下 F の欄の見出し。 -const PRESS_HEADER: &str = "押下F"; +const PRESS_HEADER: &str = "press"; /// 持続 F の欄の見出し。 -const HOLD_HEADER: &str = "持続F"; +const HOLD_HEADER: &str = "hold"; /// 取りこぼしのあった行の背景。 const GAP_FILL: Color32 = Color32::from_rgb(70, 70, 70); @@ -58,6 +64,8 @@ pub struct Settings { pub fps: f64, /// 縦リストに積む試行数。 pub trials_shown: usize, + /// 何も押していない区間を行にするか。 + pub show_released: bool, } impl Default for Settings { @@ -65,6 +73,7 @@ impl Default for Settings { Self { fps: DEFAULT_FPS, trials_shown: DEFAULT_TRIALS_SHOWN, + show_released: DEFAULT_SHOW_RELEASED, } } } @@ -136,6 +145,12 @@ pub fn lines(trials: &[Trial], settings: &Settings) -> Vec { for trial in trials.iter().rev() { let mut starts_trial = true; for row in trial.rows() { + // 離した区間の行は持続 F しか持たない。読む行数が倍になるだけなので既定では出さない。 + // 入力と入力の間合いは、押下 F の差で読める。 + if row.held.is_empty() && !settings.show_released { + continue; + } + lines.push(Line { names: row .held @@ -199,19 +214,19 @@ pub fn show( /// どこから来ているかを思い出せる状態にしておく。 fn show_header(ui: &mut Ui, status: &Status, settings: &Settings, notices: &[String]) { match &status.device_name { - Some(name) => ui.label(format!("接続: {name}")), + Some(name) => ui.label(format!("connected: {name}")), None => ui.label( RichText::new(match &status.disconnected_reason { - Some(reason) => format!("未接続: {reason}"), - None => "未接続".to_owned(), + Some(reason) => format!("disconnected: {reason}"), + None => "disconnected".to_owned(), }) .color(Color32::LIGHT_RED), ), }; match status.scale_us_per_tick { - Some(scale) => ui.label(format!("デバイス時刻の単位: {scale:.4}µs/tick")), - None => ui.label("デバイス時刻の単位: 未取得"), + Some(scale) => ui.label(format!("device clock: {scale:.4} us/tick")), + None => ui.label("device clock: not measured yet"), }; if let Some(warning) = &status.scale_warning { @@ -220,11 +235,11 @@ fn show_header(ui: &mut Ui, status: &Status, settings: &Settings, notices: &[Str let frame_us = settings.frame_us(); ui.label(format!( - "USB のレポート間隔 4ms が量子化誤差になるため、F は ±{:.2}F の幅を持つ", + "USB reports every 4ms, so each frame count carries +/-{:.2}F of error", USB_REPORT_INTERVAL_US / frame_us )); ui.label(format!( - "{:.0}fps の独自グリッドで数えるので、ゲーム内のフレーム数とは最大 1F ずれる", + "counted on a private {:.0}fps grid, may differ from the game by up to 1F", settings.fps.max(MIN_FPS) )); @@ -234,16 +249,13 @@ fn show_header(ui: &mut Ui, status: &Status, settings: &Settings, notices: &[Str } } -/// どの欄が何かを示す見出し。行と同じ割り付けを使うので、列とずれない。 +/// どの欄が何かを示す見出し。行と同じ組み方なので、列とずれない。 fn show_column_headers(ui: &mut Ui) { - show_columns( - ui, - |ui| { - ui.label(header_text(NAMES_HEADER)); - }, - header_text(PRESS_HEADER), - header_text(HOLD_HEADER), - ); + ui.label(header_text(&columns( + NAMES_HEADER, + PRESS_HEADER, + HOLD_HEADER, + ))); } /// 見出しの文字。数字より小さく弱くして、行の数字から視線を奪わない。 @@ -260,44 +272,26 @@ fn show_line(ui: &mut Ui, line: &Line) { }; frame.show(ui, |ui| { - show_columns( - ui, - |ui| { - for name in &line.names { - ui.monospace(*name); - } - }, - RichText::new(press_text(line.press_frames)).monospace(), - RichText::new(hold_text(line.hold_frames)).monospace(), - ); + ui.monospace(row_text(&line.names, line.press_frames, line.hold_frames)); }); } -/// 名前の欄と F の 2 欄を割り付ける。 -fn show_columns(ui: &mut Ui, names: impl FnOnce(&mut Ui), press: RichText, hold: RichText) { - ui.horizontal(|ui| { - let row_height = ui.spacing().interact_size.y; - let names_width = (ui.available_width() - 2.0 * FRAMES_COLUMN_WIDTH).max(0.0); - - ui.allocate_ui_with_layout( - Vec2::new(names_width, row_height), - Layout::left_to_right(Align::Center), - names, - ); - - show_frames_column(ui, row_height, press); - show_frames_column(ui, row_height, hold); - }); +/// 1 行ぶんの文字列。 +/// +/// 3 つの欄を別々の widget にしない。egui は要求した幅ではなく実際に使った矩形のぶんだけ +/// cursor を進めるので、名前の欄に固定幅を渡しても名前が長い行で F の列が右へずれる。 +/// 等幅の 1 行に組めば、割り付けを egui に任せずに桁が揃う。 +pub fn row_text(names: &[&str], press_frames: Option, hold_frames: f64) -> String { + columns( + &names.join(" "), + &press_text(press_frames), + &hold_text(hold_frames), + ) } -fn show_frames_column(ui: &mut Ui, row_height: f32, text: RichText) { - ui.allocate_ui_with_layout( - Vec2::new(FRAMES_COLUMN_WIDTH, row_height), - Layout::right_to_left(Align::Center), - |ui| { - ui.label(text); - }, - ); +/// 名前を左詰め、F の 2 欄を右詰めにして 1 行にする。 +fn columns(names: &str, press: &str, hold: &str) -> String { + format!("{names:FRAMES_WIDTH$}{hold:>FRAMES_WIDTH$}") } /// 押下 F の表示。押されていない区間では空欄にする。 diff --git a/tests/ui.rs b/tests/ui.rs index 43c7d5a..660e049 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -7,8 +7,8 @@ use frametap::history::{History, DEFAULT_RETAIN_US, DEFAULT_TRIAL_GAP_US}; use frametap::report_decode::{Buttons, Direction}; use frametap::timeline::{EventKind, InputEvent, Target}; use frametap::ui::{ - hold_text, lines, press_text, recent_trials, show, Settings, Status, DEFAULT_TRIALS_SHOWN, - HOLD_OVERFLOW_TEXT, + hold_text, lines, press_text, recent_trials, row_text, show, Settings, Status, + DEFAULT_TRIALS_SHOWN, HOLD_OVERFLOW_TEXT, }; /// 60fps の 1F。 @@ -56,13 +56,90 @@ fn frames_count_from_the_trial_origin() { let lines = lines(&history.recent_trials(5), &Settings::default()); assert_eq!(lines.len(), 2); - assert_eq!(lines[0].names, vec!["L1", "D↑"]); + assert_eq!(lines[0].names, vec!["L1", "DP8"]); assert_eq!(lines[0].press_frames, Some(0.0)); - assert_eq!(lines[1].names, vec!["L1", "D↑", "R1"]); + assert_eq!(lines[1].names, vec!["L1", "DP8", "R1"]); assert_eq!(press_text(lines[1].press_frames), "1.0"); assert_eq!(hold_text(lines[1].hold_frames), "2.0"); } +/// 既定では何も押していない区間を行にしない。間合いは押下 F の差で読む。 +#[test] +fn released_intervals_do_not_become_lines() { + let mut history = history(); + history.push(&[press(Target::Button(Buttons::L1), 0)], 0); + history.push(&[release(Target::Button(Buttons::L1), FRAME_US)], FRAME_US); + history.push( + &[press(Target::Button(Buttons::R1), 5 * FRAME_US)], + 5 * FRAME_US, + ); + history.push(&[], 6 * FRAME_US); + + let lines = lines(&history.recent_trials(1), &Settings::default()); + + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].names, vec!["L1"]); + assert_eq!(lines[1].names, vec!["R1"]); + // 5F 空けたことは押下 F の差に出る。 + assert_eq!(press_text(lines[0].press_frames), "0.0"); + assert_eq!(press_text(lines[1].press_frames), "5.0"); +} + +/// show_released を立てると離した区間も行になる。既定との対になる。 +#[test] +fn show_released_brings_the_released_intervals_back() { + let mut history = history(); + history.push(&[press(Target::Button(Buttons::L1), 0)], 0); + history.push(&[release(Target::Button(Buttons::L1), FRAME_US)], FRAME_US); + history.push( + &[press(Target::Button(Buttons::R1), 5 * FRAME_US)], + 5 * FRAME_US, + ); + history.push(&[], 6 * FRAME_US); + + let settings = Settings { + show_released: true, + ..Settings::default() + }; + let lines = lines(&history.recent_trials(1), &settings); + + assert_eq!(lines.len(), 3); + assert!(lines[1].names.is_empty()); + assert_eq!(press_text(lines[1].press_frames), ""); + assert_eq!(hold_text(lines[1].hold_frames), "4.0"); +} + +/// 1 行の文字列は列の位置が名前の長さで動かない。同時押しでも F の桁が揃う。 +#[test] +fn a_row_keeps_its_columns_when_names_grow() { + let one = row_text(&["L1"], Some(0.0), 1.0); + let many = row_text(&["L1", "DP8", "R1"], Some(12.5), 99.0); + + assert_eq!(one.len(), many.len()); + // 名前の欄は左詰め、F の 2 欄は右詰めで同じ位置に来る。 + assert!(one.starts_with("L1 "), "{one}"); + assert!(one.ends_with("1.0"), "{one}"); + assert!(many.starts_with("L1 DP8 R1 "), "{many}"); + assert!(many.ends_with("99.0"), "{many}"); +} + +/// 方向は numpad 表記で出す。十字キーと左スティックを接頭辞で分ける。 +#[test] +fn directions_use_numpad_names() { + let mut history = history(); + history.push( + &[ + press(Target::Dpad(Direction::SW), 0), + press(Target::Stick(Direction::E), 0), + ], + 0, + ); + + let lines = lines(&history.recent_trials(1), &Settings::default()); + + assert_eq!(lines[0].names, vec!["DP1", "LS6"]); +} + /// 長押しの間は行が増えず、最後の行の持続 F だけが伸びる。 #[test] fn holding_grows_the_last_line_without_adding_lines() { @@ -105,20 +182,17 @@ fn lines_run_oldest_first_and_mark_each_trial_head() { let trials = history.recent_trials(DEFAULT_TRIALS_SHOWN); let lines = lines(&trials, &Settings::default()); - // 各試行は押下の行と離した後の空の行を持つ。 + // 離した後の区間は行にならないので、各試行は押下の 1 行だけになる。 assert_eq!( lines .iter() .map(|line| line.starts_trial) .collect::>(), - vec![true, false, true, false, true, false], + vec![true, true, true], ); // 古い試行が先。試行内の押下 F は起点からの差なので、どの試行でも 0.0 から始まる。 assert_eq!(lines[0].press_frames, Some(0.0)); assert_eq!(lines[0].names, vec!["Cross"]); - // 空の区間は名前も押下 F も持たず、長さだけを出す。 - assert!(lines[1].names.is_empty()); - assert_eq!(press_text(lines[1].press_frames), ""); } /// 試行数の上限は [`frametap::ui::recent_trials`] が [`Settings`] から読む。新しい方を残す。