From d2117972c4c08d5c3a4aadd56c04dc2201d955a9 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 19:50:02 +0900 Subject: [PATCH 1/2] docs: plan the cross-session search in eight tasks The spec in #40 said what to build. This says how, in the order it gets built, with the exact signatures and the test that proves each step. Two places where the plan refines the spec rather than restating it: - Counting pushed lines. The spec says to count every linefeed in the primary screen. A linefeed above the bottom line pushes nothing into history, so that overcounts. The plan takes the history_size delta, which is exact below the scrollback cap, and only estimates when the delta is zero because the cap is reached. - Enter verifies. Since the count can still drift, Enter checks that the line it computed still holds the query and hands over to find when it does not. Redactor::redact is behind #[cfg(test)] today; the result rows need it, so Task 4 takes the gate off. Co-Authored-By: Claude Opus 5 --- .../plans/2026-10-28-cross-session-search.md | 674 ++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-cross-session-search.md diff --git a/docs/superpowers/plans/2026-10-28-cross-session-search.md b/docs/superpowers/plans/2026-10-28-cross-session-search.md new file mode 100644 index 0000000..f0a1282 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-cross-session-search.md @@ -0,0 +1,674 @@ +# 全セッション検索 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 左ペインの左上の検索窓に打った語で全セッションのスクロールバックを探し、当たった文を新しい順に左ペインへ並べ、選べばその場所へ飛ぶ。 + +**Architecture:** 当たりの新旧を決めるため、pty の読み手で `Handler` を包み、履歴へ押し出した行の総数(`pushed`)を数える。行の通し番号は `pushed + line` で出し、`(pushed, Instant)` の環から時刻を引く。走査は別スレッドで、各セッションの `FairMutex` を数百行ごとに取り直しながら走る。結果は `UiEvent` で UI スレッドへ返し、左ペインの一覧を差し替える。 + +**Tech Stack:** Rust 2021、`alacritty_terminal` 0.26(`vte` 0.15 の `Handler`)、`winit`、既存の `Renderer`(wgpu + glyphon)。新しい依存は足さない。 + +**Spec:** [`docs/superpowers/specs/2026-10-28-cross-session-search-design.md`](../specs/2026-10-28-cross-session-search-design.md) + +## Global Constraints + +- 走査は別スレッド。UI スレッドで 10000 行を走らせない(仕様 §10)。測った余白は 1 フレーム 16.61ms 中 0.3ms。 +- 各セッションの `Term` の錠は数百行ごとに放す。全文を写してから走る案は採らない(§10)。 +- 素の文字列一致、大文字小文字は無視、正規表現なし(§7)。 +- 1 本あたりの採用は 20 件、全体 200 件、連続する同じ文は 1 件(§8)。 +- 結果の 2 段目は `Redactor` を通してから出す(§6)。 +- 代替画面のセッションは、いま出ている画面しか探せない。穴として受け入れる(§7)。 +- `Ctrl` の鍵は増やさない。鍵は `⌘⇧F` だけ(§9)。 +- 変換中の文字列は検索に渡さない。`Ime::Commit` だけを受ける(§9)。 +- コードのコメントと `docs/` は日本語、コミットと PR は英語。試験の名前は日本語の文。 +- 1 つの Task = 1 ブランチ = 1 PR ではない。**この計画全体で 1 本のブランチ**とし、Task ごとにコミットする。 + +## Review Focus + +仕様が黙っているが、使えば当たる入力。各行の試験は、その行を持つ Task の中に置いてある。 + +- **語が空白だけ**のとき、結果 0 件の一覧ではなく元のセッション一覧へ戻ること(Task 6)。 +- **代替画面のセッション**(`vim`、`less`)を含めて走っても落ちず、そのセッションからは画面のぶんだけ出ること(Task 4)。 +- **名前・題名・枝に当たった件**で `Enter` を押したとき、通し番号が無いので送り先が無い。切り替えるだけで終わること(Task 7)。 +- **走査中に閉じられたセッション**の件を選んだとき、添字がずれている。`SessionId` で引き直し、無ければ何もしないこと(Task 7)。 +- **全行が当たる語**(`e` の 1 文字など)で、上限で止まり、UI が固まらないこと(Task 4)。 + +--- + +### Task 1: `⌘⇧F` を `⌘F` から分ける + +いまは物理キー側の分岐が Cmd のとき Shift を見ていないため、`⌘⇧F` が `FindInScreen` に落ちる(仕様 §9)。新しい鍵を足す前に、ここを分ける。 + +**Files:** +- Modify: `src/input.rs:9-33`(`Action`)、`src/input.rs:118-121`(文字側の Cmd+Shift)、`src/input.rs:166-193`(物理キー側の Cmd) +- Test: `src/input.rs` の `mod tests`(同じファイル内) + +**Interfaces:** +- Produces: `Action::FindAcrossSessions` + +- [ ] **Step 1: 失敗する試験を書く** + +```rust +#[test] +fn コマンドとシフトのFは横断検索になる() { + let cs = ModifiersState::SUPER | ModifiersState::SHIFT; + // 文字側(通常の配列)。 + assert_eq!(action_for(&ch("f"), PhysicalKey::Code(KeyCode::KeyF), cs), Some(Action::FindAcrossSessions)); + // 物理キー側(文字が取れない配列)。 + assert_eq!(action_for(&Key::Named(NamedKey::Shift), PhysicalKey::Code(KeyCode::KeyF), cs), Some(Action::FindAcrossSessions)); +} + +#[test] +fn コマンドだけのFは画面内検索のまま() { + let cmd = ModifiersState::SUPER; + assert_eq!(action_for(&ch("f"), PhysicalKey::Code(KeyCode::KeyF), cmd), Some(Action::FindInScreen)); +} + +#[test] +fn コマンドとシフトはFとRだけ受ける() { + let cs = ModifiersState::SUPER | ModifiersState::SHIFT; + let phys = PhysicalKey::Code(KeyCode::KeyR); + assert_eq!(action_for(&ch("r"), phys, cs), Some(Action::RenameSession)); + // ⌘⇧N は、いまは物理キー側で NewSession に落ちている。落ちないこと。 + let n = PhysicalKey::Code(KeyCode::KeyN); + assert_eq!(action_for(&Key::Named(NamedKey::Shift), n, cs), None); +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib input` +Expected: FAIL(`FindAcrossSessions` が無い) + +- [ ] **Step 3: `Action::FindAcrossSessions` を足し、両方の経路で Shift を分ける** + +`Action` に `/// 全セッションの出力を探す。` を添えて追加する。 +`action_from_char` の Cmd+Shift の分岐(いま `"r"` だけを見ている箇所)を `"f" => FindAcrossSessions`、`"r" => RenameSession`、他は `None` にする。 +`action_from_physical` の Cmd の分岐の先頭で Shift を分け、Shift が付いているときは `KeyF => FindAcrossSessions`、`KeyR => RenameSession`、他は `None` を返す。`INDEX_KEYS` の照合も Shift のときは通さない。 + +- [ ] **Step 4: 通ることを確かめる** + +Run: `cargo test --lib input && cargo clippy --all-targets -- -D warnings` +Expected: PASS(既存の `⌘F`・`⌘⇧R`・`^` 側の試験も通ったまま) + +- [ ] **Step 5: コミット** + +```bash +git add src/input.rs +git commit -m "feat: give cmd-shift-f its own action instead of falling through to find" +``` + +--- + +### Task 2: 行の通し番号を数える + +`Grid::history_size()` はスクロールバック上限で頭打ちになり、行から時刻が引けない(仕様 §8)。`Term` へ渡す前に `Handler` を包み、履歴へ押し出した行を自分で数える。 + +**Files:** +- Create: `src/lines.rs` +- Modify: `src/main.rs:3-25`(`mod lines;` を足す)、`src/pty.rs:68-80`(`Spawned`)、`src/pty.rs:170-205`(`spawn`)、`src/pty.rs:323-416`(`spawn_reader`)、`src/session.rs:26-88`(`Session`)、`src/session.rs:497-668`(`spawn_new` と `fork` の組み立て) +- Test: `src/lines.rs` の `mod tests` + +**Interfaces:** +- Produces: + - `pub struct Counting<'a>` — `Counting::new(inner: &'a mut Term, pushed: u64) -> Self`、`fn pushed(&self) -> u64`、`impl vte::ansi::Handler` + - `pub struct Ticks` — `Ticks::new() -> Self`、`fn push(&mut self, pushed: u64, at: Instant)`、`fn time_of(&self, serial: i64) -> Option` + - `pub fn serial(pushed: u64, line: i32) -> i64` + - `pub fn line_of(serial: i64, pushed: u64, history_size: usize) -> Option` + - `Session.pushed: Arc`、`Session.ticks: Arc>`(`Spawned` にも同じ 2 つ) + +- [ ] **Step 1: 失敗する試験を書く** + +`Counting` は `Term` を要るので、試験は `crate::term::new_term` で 1 本立てて `Processor` から流す(`src/pty.rs:721-780` の既存の試験が同じ形を取っている)。 + +```rust +#[test] +fn 本画面の改行で通し番号が増える() { + // 24 行の Term に 30 行流す。6 行が履歴へ出る。 + // pushed == 6 +} + +#[test] +fn 代替画面にいるあいだは数えない() { + // DECSET 1049 → 30 行 → DECRST 1049。pushed は 0 のまま。 +} + +#[test] +fn 上限に達しても増え続ける() { + // scrollback 4 行の Term に 40 行流す。history_size は 4 で止まるが + // pushed は 36 まで伸びる。 +} + +#[test] +fn 通し番号から行位置を出す() { + assert_eq!(serial(100, 0), 100); + assert_eq!(serial(100, -3), 97); + // 20 行流れたあと、通し番号 97 は 23 行上にある。 + assert_eq!(line_of(97, 120, 10000), Some(-23)); + // 履歴から押し出されていれば無い。 + assert_eq!(line_of(97, 120, 10), None); +} + +#[test] +fn 目盛りから時刻を引く() { + // (10, t0) (20, t1) (30, t2) を入れ、通し番号 25 で t1 が出ること。 + // 一番古いものより前の通し番号では、残っている最古の時刻が出ること。 +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib lines` +Expected: FAIL(`src/lines.rs` が無い) + +- [ ] **Step 3: `src/lines.rs` を書く** + +`Counting` は `inner: &'a mut Term` と `pushed: u64` を持つ。`Handler` の 71 本は**すべて unit を返す**ので、1 つの macro で畳める。一覧は +`~/.cargo/registry/src/*/vte-0.15.0/src/ansi.rs` の `pub trait Handler` から取る +(`awk '/^pub trait Handler/,/^}/' src/ansi.rs | grep -E '^\s+fn '`)。 + +```rust +macro_rules! forward { + ($( fn $name:ident ( $( $arg:ident : $ty:ty ),* ); )*) => { + $( #[inline] fn $name(&mut self $(, $arg: $ty)*) { self.inner.$name($($arg),*) } )* + }; +} +``` + +`linefeed` と `scroll_up` だけ手で書き、押し出した行を数える。数え方は、 +**上限に届くまでは `history_size()` の差が正確**なので差を使い、差が 0 かつ +上限に達しているときだけ見積もる。 + +```rust +fn linefeed(&mut self) { + let alt = self.inner.mode().contains(TermMode::ALT_SCREEN); + let before = self.inner.grid().history_size(); + let bottom = self.inner.grid().cursor.point.line.0 as usize + 1 + == self.inner.grid().screen_lines(); + self.inner.linefeed(); + if !alt { + let d = self.inner.grid().history_size() - before; + self.pushed += if d > 0 { d as u64 } else { bottom as u64 }; + } +} +``` + +`scroll_up(n)` も同じ形で、差が 0 なら `n` を足す。 +`Ticks` は `VecDeque<(u64, Instant)>` の環で、容量は `pub const TICKS: usize = 4096`(約 32KB)。 +`push` は前の `pushed` と同じなら積まない。`time_of` は `partition_point` で引く。 +`line_of` は `serial - pushed as i64` を出し、`-(history_size as i64)` より小さければ `None`。 + +- [ ] **Step 4: 通ることを確かめる** + +Run: `cargo test --lib lines` +Expected: PASS + +- [ ] **Step 5: 読み手に繋ぐ** + +`spawn_reader` に `pushed: Arc` と `ticks: Arc>` を渡す(`ReaderFlags` に 2 つ足す)。 +`parser.advance(&mut *term, ..)` を `Counting` 越しにする。錠を持っている区間の外で +`pushed.store(..)` と `ticks.lock().push(..)`(`Instant::now()`)を 1 読みにつき 1 回行う。 +`Spawned` と `Session` に同じ 2 つを持たせ、`spawn_new` と `fork` の両方で埋める。 + +- [ ] **Step 6: 動かして確かめる** + +Run: `cargo test --all-targets && cargo clippy --all-targets -- -D warnings` +Expected: PASS(`src/pty.rs` の既存の pty 試験も通ったまま) + +- [ ] **Step 7: コミット** + +```bash +git add src/lines.rs src/main.rs src/pty.rs src/session.rs +git commit -m "feat: count the lines pushed into history so a row can be dated" +``` + +--- + +### Task 3: 1 行の中の当たりと、当たりを中心に詰める + +**Files:** +- Create: `src/scan.rs` +- Modify: `src/main.rs`(`mod scan;`)、`src/render.rs:575-610`(`fit_center` を足す) +- Test: `src/scan.rs` の `mod tests` + +**Interfaces:** +- Produces: + - `pub fn find_hit(text: &str, needle_lower: &str) -> Option>` — 大文字小文字を無視した素の一致。返す範囲は `text` のバイト位置。 + - `pub fn window_around(chars: &[char], hit: Range, keep: usize) -> (Range, bool, bool)` — 残す範囲(文字の添字)と、頭・尾を落としたか。`hit` も**文字の添字**で渡す(`find_hit` が返すのはバイト位置なので、呼ぶ側で直す)。 + - `Renderer::fit_center(&mut self, text: &str, style: TextStyle, max_px: f32, hit: Range) -> (String, Range)` + +- [ ] **Step 1: 失敗する試験を書く** + +```rust +#[test] +fn 大文字小文字を無視して探す() { + assert_eq!(find_hit("この SESマッチ機能", "ses"), Some(3..6)); + assert_eq!(find_hit("ses", "SES"), None); // needle は小文字で渡す約束 + assert_eq!(find_hit("abc", "x"), None); +} + +#[test] +fn 小文字にして長さが変わっても位置を戻せる() { + // 'İ'(U+0130、3 バイト)の小文字は 2 文字 4 バイト。 + // 元の文字列のバイト位置が返ること。 + let t = "aİb"; + assert_eq!(find_hit(t, "b"), Some(4..5)); +} + +#[test] +fn 当たりを真ん中に残す() { + let chars: Vec = "0123456789ABCDEFGHIJ".chars().collect(); + // 当たりは 10..12。20 字のうち 8 字だけ残すなら、前後を均等に落とす。 + let (keep, head, tail) = window_around(&chars, 10..12, 8); + assert!(keep.start <= 10 && keep.end >= 12); + assert_eq!(keep.end - keep.start, 8); + assert!(head && tail); +} + +#[test] +fn 当たりが端にあるときは片側だけ落とす() { + let chars: Vec = "0123456789".chars().collect(); + let (keep, head, tail) = window_around(&chars, 0..2, 5); + assert_eq!(keep, 0..5); + assert!(!head && tail); +} + +#[test] +fn 当たりが残す幅より長ければ当たりの頭から残す() { + let chars: Vec = "0123456789".chars().collect(); + let (keep, _, _) = window_around(&chars, 2..9, 3); + assert_eq!(keep, 2..5); +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib scan` +Expected: FAIL + +- [ ] **Step 3: `find_hit` と `window_around` を書く** + +`find_hit` は `text.char_indices()` を回しながら小文字の文字列と「小文字側のバイト位置 → 元のバイト位置」の対応表を作り(`src/main.rs:1205` の `masked_cells` が同じ作りをしている)、`lower.find(needle_lower)` の結果を対応表で戻す。 +`window_around` は、当たりの中心から前後へ均等に伸ばし、片側が尽きたら残りをもう片側へ回す。 + +- [ ] **Step 4: 通ることを確かめる** + +Run: `cargo test --lib scan` +Expected: PASS + +- [ ] **Step 5: `Renderer::fit_center` を足す** + +既存の `fit`/`build` と同じ組みにする。収まるならそのまま返す。収まらないなら +`hit` のバイト位置を文字の添字に直し、比から残す字数の当たりを付け、`window_around` +で組み、`…` を落とした側に付けて `measure_px` で合わせ込む。返す `Range` は、 +組み立てた文字列の中での当たりの**バイト位置**(`…` のぶんずれる)。 +試験は `window_around` にあるので、ここは薄く保つ。 + +- [ ] **Step 6: コミット** + +```bash +git add src/scan.rs src/render.rs src/main.rs +git commit -m "feat: match a line case-insensitively and keep the hit centered" +``` + +--- + +### Task 4: 全セッションを走る + +**Files:** +- Modify: `src/scan.rs`、`src/secret.rs:109-123`(`redact` の `#[cfg(test)]` を外す) +- Test: `src/scan.rs` の `mod tests` + +**Interfaces:** +- Consumes: Task 2 の `lines::{serial, Ticks}`、Task 3 の `find_hit` +- Produces: + - `pub struct Target { pub id: SessionId, pub term: Arc>>, pub pushed: Arc, pub ticks: Arc>, pub name: String, pub title: Option, pub branch: Option }` + - `pub enum HitKind { Line, Name, Title, Branch }` + - `pub struct Hit { pub id: SessionId, pub kind: HitKind, pub serial: Option, pub at: Option, pub text: String, pub hit: Range }` + - `pub struct Limits { pub per_session: usize, pub total: usize, pub chunk: usize }` — `Default` は 20 / 200 / 256 + - `pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits) -> Vec` + +- [ ] **Step 1: 失敗する試験を書く** + +`Target` を 2 本作り(`crate::term::new_term` に `Processor` で流し込む)、`scan` にかける。 + +```rust +#[test] +fn 新しい順に並ぶ() { + // 本 A に古い当たり、本 B に新しい当たりを入れ、B が先に来ること。 +} + +#[test] +fn 一件は一当たり() { + // 同じセッションの別の行 3 本が当たれば 3 件返ること。 +} + +#[test] +fn 連続する同じ文は一件にまとめる() { + // 同じ文を 5 行続けて流し、1 件になること。 +} + +#[test] +fn 一本あたりの上限で他の本の席を残す() { + // A に 50 当たり、B に 1 当たり。per_session=20 なら A は 20 件、B は 1 件。 +} + +#[test] +fn 全体の上限で止まる() { + // total=10 なら 10 件で返ること(Review Focus: 全行が当たる語)。 +} + +#[test] +fn 伏せ字の中の当たりは出さない() { + // AKIA から始まる鍵を含む行を流し、鍵の一部を語にしても 0 件。 + // 行の文字列にも鍵が残っていないこと。 +} + +#[test] +fn 名前と題名と枝も探す() { + // name="SESマッチ" に対して "ses" が HitKind::Name で当たり、serial は None。 +} + +#[test] +fn 代替画面でも落ちない() { + // DECSET 1049 の Term を混ぜても panic せず、画面のぶんが出ること + //(Review Focus: 代替画面のセッション)。 +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib scan` +Expected: FAIL + +- [ ] **Step 3: `scan` を書く** + +形: + +1. `query` を小文字にする。空か空白だけなら `Vec::new()`。 +2. 各 `Target` について、名前・題名・枝を `find_hit` にかける(`serial: None`、`at: None`)。 +3. 行は**新しい方から**、`Line(screen_lines-1)` から `Line(-(history_size))` へ遡る。 + `limits.chunk` 行ごとに錠を取り直す(`term.lock()` を抜けてから次の塊を取る)。 + 塊の境目で `history_size()` を取り直し、縮んでいれば遡りを止める。 +4. 1 行の文字列は `WIDE_CHAR_SPACER` を飛ばして組む(`src/session.rs:900-930` と同じ規則)。 + 末尾の空白は落とす。空行は飛ばす。 +5. `redactor.redact(&text)` を通したうえで `find_hit` にかける。 + 当たらなければ捨てる(伏せ字に隠れた当たりは出さない)。 +6. 直前に採った行と同じ文字列なら積まない(連続の畳み込み)。 +7. `serial = lines::serial(pushed.load(Relaxed), line)`、`at = ticks.lock().time_of(serial)`。 +8. 1 本で `per_session` に達したら、その本を打ち切る。 +9. 全本を集めてから `at` の新しい順に並べ、`total` で切る。 + `at` が無い件(名前・題名・枝)は先頭に置く。 + +- [ ] **Step 4: 通ることを確かめる** + +Run: `cargo test --lib scan && cargo clippy --all-targets -- -D warnings` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add src/scan.rs src/secret.rs +git commit -m "feat: scan every session newest first, under a per-session cap" +``` + +--- + +### Task 5: 別スレッドで走らせ、結果を受ける + +**Files:** +- Modify: `src/term.rs`(`UiEvent` に 1 つ足す)、`src/main.rs:268-325`(`State`)、`src/main.rs:568-600`(`user_event`)、`src/main.rs:1980-2005`(起こす時刻) +- Test: `src/main.rs` の `mod tests` + +**Interfaces:** +- Consumes: `scan::{scan, Target, Hit, Limits}` +- Produces: + - `UiEvent::Scanned(u64, Vec)` — 第 1 要素は世代。 + - `pub(crate) struct CrossSearch { pub query: String, pub generation: u64, pub typed_at: Option, pub hits: Vec, pub selected: usize, pub offset: usize }` — `#[derive(Default)]`(`⌘⇧F` で空のまま置く) + - `CrossSearch::accept(&mut self, generation: u64, hits: Vec) -> bool`、`CrossSearch::follow(&mut self)` + - `State.across: Option` + - `const SCAN_DEBOUNCE: Duration = Duration::from_millis(150)` + - `fn start_scan(&mut self)` / `fn queue_scan(&mut self)` + +- [ ] **Step 1: 失敗する試験を書く** + +```rust +#[test] +fn 古い世代の結果は捨てる() { + // generation=2 を待っているあいだに届いた generation=1 の結果で + // hits が入れ替わらないこと。 + // CrossSearch::accept(&mut self, generation: u64, hits: Vec) -> bool を試す。 +} + +#[test] +fn 選んでいる件が窓から出ないよう追う() { + // CrossSearch::follow() が offset を動かすこと(SearchState::follow と同じ約束)。 +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib` +Expected: FAIL + +- [ ] **Step 3: `CrossSearch` と受け口を書く** + +`accept` は `generation` が自分のものと同じときだけ `hits` を入れ替え、`selected`/`offset` を 0 に戻して `true` を返す。 +`queue_scan` は `typed_at = Some(Instant::now())` を置くだけ。 +`start_scan` は `generation += 1` し、`Target` の列(`Arc` の複製のみ。`Term` の中身は写さない)と `redactor.clone()` と語を `std::thread::spawn` へ渡し、終わったら `EventLoopProxy` で `UiEvent::Scanned` を送る。 +`Redactor` が `Clone` でなければ `Arc` にして `State` と共有する。 + +- [ ] **Step 4: デバウンスを繋ぐ** + +描画の最後の `want(at)` に、`across` の `typed_at` があれば `typed_at + SCAN_DEBOUNCE` を足す。 +その時刻を過ぎていて語が空でなければ `start_scan` を呼び、`typed_at` を `None` にする。 +`user_event` に `UiEvent::Scanned` を足し、`accept` が `true` なら `request_redraw`。 + +- [ ] **Step 5: 通ることを確かめる** + +Run: `cargo test --all-targets && cargo clippy --all-targets -- -D warnings` +Expected: PASS + +- [ ] **Step 6: コミット** + +```bash +git add src/term.rs src/main.rs +git commit -m "feat: run the scan off the UI thread and drop stale generations" +``` + +--- + +### Task 6: 検索窓と結果の一覧を描く + +**Files:** +- Modify: `src/main.rs:2064-2096`(`mod sidebar` に定数)、`src/main.rs:2180-2220`(`sidebar_layout`)、`src/main.rs:2250-2280`(`sidebar_hit`)、`src/main.rs:2283-2300`(`draw_sidebar` の帯)、`src/main.rs:890-915`(鍵の振り分け)、`src/main.rs:802-828`(`Ime::Commit`)、`src/main.rs:1005-1020`(`on_action`) +- Test: `src/main.rs` の `mod search_view_tests`(既にある) + +**Interfaces:** +- Consumes: `CrossSearch`、`scan::Hit`、`Renderer::fit_center` +- Produces: + - `sidebar::SIZE_QUERY: f32 = 12.0`、`sidebar::HIT_PAD: f32 = 4.0` + - `fn showing_hits(across: Option<&CrossSearch>) -> bool` — 語が空か空白だけなら偽 + - `fn across_blocks(state: &State, layout: &Layout) -> Vec`(`SidebarLayout.blocks` を結果で作る) + - `fn on_across_key(&mut self, key: &Key, event: &KeyEvent, mods: ModifiersState)` + - `SidebarHit::SearchBox`、`SidebarHit::Hit(usize)` + +- [ ] **Step 1: 失敗する試験を書く** + +```rust +#[test] +fn 語が空白だけなら元の一覧に戻る() { + // Review Focus: across.query が " " のとき、結果の一覧ではなく + // セッションの一覧を組むこと。判定は fn showing_hits(across: Option<&CrossSearch>) -> bool + assert!(!showing_hits(Some(&cross(" ")))); + assert!(!showing_hits(Some(&cross("")))); + assert!(showing_hits(Some(&cross("ses")))); +} + +#[test] +fn 結果の行は二段ぶんの高さを取る() { + // across_blocks の各 Block の height が + // PAD_TOP + PAD_BOTTOM + line_height(SIZE_TITLE) + line_height(SIZE_SUB) であること。 +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib search_view` +Expected: FAIL + +- [ ] **Step 3: 帯に窓を描く** + +`draw_sidebar` の `+` を描く前に、`across` があれば帯(`sidebar::HEADER`)の左側へ +`🔍 ` と語を `SIZE_QUERY` で描き、`+` の左までを窓の幅にする。`surface` を敷き、 +入っているあいだは `accent` の縁と `cursor` の棒を出す。`state.cursor_px` を窓の +位置にして、変換中の文字列(`draw_preedit_px`)がここへ出るようにする。 +窓は `across` が `None` のあいだも薄く見せる(仕様 §12「窓は常に見えている」)。 + +- [ ] **Step 4: 一覧を結果に差し替える** + +`showing_hits` が真のとき、`sidebar_layout` の `blocks` を `across_blocks` で作る。 +各件の 1 段目は既存の行と同じ組み(状態の点、`Session` の名前、`⌘` の札)を、 +`hit.id` から引いたセッションで描く。2 段目は `fit_center` に通し、返った +`Range` のぶんに `theme.search_hit` を敷いてから文字を重ねる。 +入りきらない件は `offset` から `blocks` の数だけ出す。並べ替えとドラッグは効かせない +(`state.mouse.sidebar_drag` を作らない)。 + +- [ ] **Step 5: 鍵と当たり判定を繋ぐ** + +`Action::FindAcrossSessions` で `picker`/`search`/`find` を `None` にし、 +`across = Some(CrossSearch::default())` を置く。 +`on_key` の振り分けに `across.is_some()` の分岐を足し、`on_across_key` へ渡す。 +`on_across_key`:`Esc` で `across = None`、`↑`/`↓` で `selected` を動かして `follow()`、 +`Backspace` と文字で `query` を変えて `queue_scan()`、`Enter` は Task 7。 +`event.text` が制御文字、または `preedit` が空でないときは受けない(`on_find_key` と同じ)。 +`Ime::Commit` の分岐に `across` を足す(`rename` の次、`find` の前)。 +`sidebar_hit` は、帯のうち `+` の左を `SearchBox`、結果の行を `Hit(n)` として返す。 + +- [ ] **Step 6: 通ることを確かめる** + +Run: `cargo test --all-targets && cargo clippy --all-targets -- -D warnings && cargo fmt --check` +Expected: PASS + +- [ ] **Step 7: 目で確かめる** + +Run: `cargo run --release -- --probe /tmp/across.png`(画面の取り込みは塞がれているため `--probe` を使う) +Expected: 左上に窓が出ていて、結果が 2 段で並び、当たった語に色が敷かれている + +- [ ] **Step 8: コミット** + +```bash +git add src/main.rs +git commit -m "feat: put the search box in the left pane and list the hits there" +``` + +--- + +### Task 7: 選んで飛ぶ + +**Files:** +- Modify: `src/main.rs`(`on_across_key` の `Enter`、`sidebar_hit` の `Hit`) +- Test: `src/main.rs` の `mod search_view_tests` + +**Interfaces:** +- Consumes: `lines::line_of`、`search::ScreenSearch` +- Produces: `fn open_hit(&mut self, n: usize)` + +- [ ] **Step 1: 失敗する試験を書く** + +```rust +#[test] +fn 流れた行は通し番号から引き直す() { + // 当たりを取ったあと 30 行流し、line_of が -30 ずれた行を返すこと。 +} + +#[test] +fn 消えた行なら送らずに語だけ渡す() { + // line_of が None のとき、送り先を None にして query だけ渡す決め方 + // fn jump_target(serial: Option, pushed: u64, history: usize) -> Option + assert_eq!(jump_target(None, 100, 10000), None); // 名前・題名・枝の件 + assert_eq!(jump_target(Some(97), 120, 10), None); // 履歴から消えた + assert_eq!(jump_target(Some(97), 120, 10000), Some(-23)); +} + +#[test] +fn 閉じたセッションの件は何もしない() { + // Review Focus: hit.id が sessions に無いとき、selected を動かさないこと。 +} +``` + +- [ ] **Step 2: 落ちることを確かめる** + +Run: `cargo test --lib search_view` +Expected: FAIL + +- [ ] **Step 3: `open_hit` を書く** + +1. `hits[n].id` を `manager.sessions()` から探す。無ければ何もしない。 +2. その添字へ `manager.select(i)` で切り替える。 +3. `state.find = Some(ScreenSearch::new())` に語を入れて `rebuild()` する + (`⌘F` と同じ強調が点く)。 +4. `jump_target` が `Some(line)` を返したら、`line` の文字列に語がまだ有るかを + 確かめ、有れば `scroll_to_point(Point::new(Line(line), Column(0)))`。 + 無ければ(数え違い・書き換え)`find_step(Direction::Left)` に任せる。 +5. `across = None` にして窓を閉じる。 + +- [ ] **Step 4: 通ることを確かめる** + +Run: `cargo test --all-targets && cargo clippy --all-targets -- -D warnings` +Expected: PASS + +- [ ] **Step 5: コミット** + +```bash +git add src/main.rs +git commit -m "feat: jump to a hit by its serial and fall back to find" +``` + +--- + +### Task 8: 測って、書く + +仕様 §10 は「先に測る」を要求している。走査の費用を `--bench` に足し、 +数字を `docs/performance.md` に残す。 + +**Files:** +- Modify: `src/bench.rs`、`docs/performance.md`、`README.md` +- Test: なし(測る道具そのもの) + +- [ ] **Step 1: `--bench` に走査を足す** + +`bench::run` の末尾に、`14 本 × 10000 行` を組み立てて `scan::scan` を 1 語で走らせ、 +1 回あたりの中央と p90 を出す節を足す。行の中身は毎行変え、当たりは 1 本に 1 つだけ置く +(上限で早じまいする効果が消えないようにする)。出力の体裁は既存の節に合わせる。 + +- [ ] **Step 2: 測る** + +Run: `cargo run --release -- --bench 2>&1 | tail -20` +Expected: 「n 本 × m 行」の行に数字が出る + +- [ ] **Step 3: フレームを落としていないことを確かめる** + +Run: `RUST_LOG=info TERMIT_FRAME_LOG=1 cargo run --release`(打ってから 1 回検索する) +Expected: 毎フレームの記録に、検索の前後で落ちたフレームが無い + +- [ ] **Step 4: 書く** + +`docs/performance.md` に節を足し、測った数字と、どのセッションの出力も止まらなかったことを書く。 +`README.md` の鍵の表に `⌘⇧F` を足し、代替画面のセッションでは画面しか探せないことを +穴として書く(仕様 §7)。 + +- [ ] **Step 5: コミット** + +```bash +git add src/bench.rs docs/performance.md README.md +git commit -m "docs: measure the scan and record the alt-screen hole" +``` + +--- + +## 最後に + +全部の Task が終わったら、仕様の各節に当たる実装が有ることを確かめる。 +`cargo test --all-targets`、`cargo clippy --all-targets -- -D warnings`、`cargo fmt --check` を +通してから PR を作る(`superpowers:finishing-a-development-branch`)。 From 05c6cf414a831c9d37f63190f45aa73608dcaf9a Mon Sep 17 00:00:00 2001 From: Tkc Date: Thu, 29 Oct 2026 13:01:30 +0900 Subject: [PATCH 2/2] feat: search across every session from the left pane (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: give cmd-shift-f its own action instead of falling through to find Co-Authored-By: Claude Opus 5 * fix: keep shifted symbol keys working on the physical key path Co-Authored-By: Claude Opus 5 * feat: count the lines pushed into history so a row can be dated Grid::history_size() plateaus at the scrollback cap, so a row carries no usable age once the history is full. Wrap the vte Handler on its way to the Term and count the rows pushed into the history ourselves, keeping a small ring of (pushed, Instant) marks beside it. Counting hooks every path that can grow the history, not just linefeed and scroll_up: a wrapped line, NEL, SU, DL at the top of the screen and ED 2 all reach Grid::scroll_up through Term's own linefeed, so counting only the Handler's linefeed would miss them. Co-Authored-By: Claude Opus 5 * perf: take the line delta once per advance instead of per character Counting the wrapped lines in Handler::input cost 0.6ns per character, 5-8% of the parse stage on text-heavy loads, which spec 4 rules out. Read history_size() when the wrapper is built and again when it is done instead, and take max(delta, what the line-level hooks counted): below the scrollback cap the delta is the truth and already contains the wraps the hooks cannot see, and at the cap the hooks are the only signal. The one advance that crosses the cap undercounts, bounded by that advance. Ticks now keeps one mark per second rather than one per read, so the 4096-mark ring spans about 68 minutes instead of a few busy minutes; the spec asks for one second of resolution. The --throughput harness now runs through the wrapper, because it claims to measure the real read path. Measured over both: no difference outside the harness's own run-to-run spread. Co-Authored-By: Claude Opus 5 * fix: pick the counting rule by the cap, not by whichever count is larger max(delta, hooks) let an over-counting hook win below the cap: a main screen program that narrows the scroll region with DECSTBM scrolls inside the region and pushes nothing into the history, while the line-level hooks see a linefeed on the bottom row and count it. Below the cap the delta is the truth, so use it; the hooks are only the answer once the history can no longer grow. Telling those two zero-delta cases apart needs the configured scrollback, so Counting::new takes it again and the reader threads it through ReaderFlags. The tests now rebuild the wrapper once per read, as the reader does, instead of feeding a whole script through one advance. Co-Authored-By: Claude Opus 5 * fix: count past the cap in the read that reaches it, and date bursts right Two defects the review found. The advance that straddles the cap counted only the delta, so every push after the history filled was lost. One read carries up to 64KB, which is thousands of linefeeds, and rows produced in that read then compressed into fewer serials than there were rows -- the serial is supposed to be unique per row. finish() now takes max(delta, hooked) when the history reached the cap during the advance; which rule applies is still decided by the cap, so a narrowed scroll region below the cap stays at zero. The tests feed a whole script through one advance now, the way the reader does, instead of splitting it per line. The tick carried the count from after the read, so every row of a read landed above the newest mark and resolved to the previous one. After an idle gap that dates a whole burst minutes early, which is exactly what the search must not do. Record the count from before the read instead; the residual error is one screen height of rows dated slightly new. Also: restore the scroll-region over-count to Counting's documented limits -- it is gone below the cap, not above it, since every hook reads the screen rather than the region -- and correct the ring's footprint to the 96KB a 24-byte mark actually costs. Co-Authored-By: Claude Opus 5 * fix: mark the ticks only on a read that pushed a line A read that pushes nothing still took a mark, and because equal counts are not stacked it took the one the next pushing read needed: after a burst, the prompt redraw marked (N, t_prompt), and the output of the next command -- minutes later -- also carried before == N and was dropped, so its rows resolved to the prompt's time. A prompt sits between every pair of interactive bursts, so this defeated dating in the ordinary case. A mark (p, t) claims the rows from p up to the next mark, so a read that pushed nothing has no range to claim. Ticks::mark_read(before, after, at) now owns that decision, next to the gap and ordering guards it shares, and the reader hands it both counts once per read outside the lock. Marks are no longer spent on prompt redraws, so the ring reaches further back than the 68 minutes a mark per second would give. Co-Authored-By: Claude Opus 5 * feat: match a line case-insensitively and keep the hit centered Co-Authored-By: Claude Opus 5 * feat: scan every session newest first, under a per-session cap Walk each session's screen and scrollback from the newest line back, releasing the term lock every `chunk` lines so the pty reader keeps running, and date each hit from the session's ticks after the lock is dropped. Cap at 20 hits per session and 200 overall, fold consecutive identical lines into one, and match the row only after the redactor has run so a masked credential cannot surface in plain text. Co-Authored-By: Claude Opus 5 * fix: date every line hit and leave alt-screen hits unnumbered Pin only name, title and branch hits to the top of the list. Keying that rule on `at` put a quiet session's undated screen rows above the newest hits of every other session, and the cap on the total then handed them the seats; undated rows now sort last instead. Seed the ticks ring at construction, so a session that has not pushed a line yet still dates its rows, to session start. Default builds a seeded ring too, leaving the test-only constructor as the one unseeded route; the ring tests that drive push and mark_read directly take it. Drop the serial from a hit found on the alternate screen. `pushed` does not advance there, so the number aliased an unrelated main-screen history row: time_of dated the hit from that row and the jump would have aimed at it. Those hits take their time from the newest mark instead. Clear the stale dead-code markers in lines.rs now that the scan reads serial, line_of and time_of. Co-Authored-By: Claude Opus 5 * fix: date a quiet session's rows from its construction time The seed mark took the slot `(0, t_start)`, so the first read that actually pushed a line lost its own mark to the "same count, no mark" guard and its rows were dated to session start. The case that hurts is the one this feature exists for: a session that sat at a prompt for twenty minutes before the user typed the request they are now searching for. That row is in the first pushed screenful. Store the construction time in Ticks and leave the ring empty. time_of and newest fall back to it, so a session that has never pushed a line still answers, while the first pushing read's mark is now accepted. The recording rule is untouched. The test-only unseeded constructor goes with the seed. scan's comparator moves into `newest_first`, so the undated-line branch -- a guard now rather than a live path -- is tested directly instead of through a hole in the fixture. Co-Authored-By: Claude Opus 5 * feat: run the scan off the UI thread and drop stale generations The scan walks 14 sessions by 10000 lines; the UI thread has about 0.3ms of slack per frame, so it never calls scan itself. A keystroke only stamps typed_at, the draw end starts the walk once typing has paused for 150ms, and the worker hands the hits back as UiEvent::Scanned. Every result carries the generation it was started for, so a slow walk that lands after a newer one is dropped instead of replacing it. The generation counter lives on State, not in the box, so reopening the box cannot collide with a walk still running. Co-Authored-By: Claude Opus 5 * fix: drop the hits when the word is erased, and test the debounce Erasing the word returned from start_scan before the generation was bumped, so a walk still running for the erased word came back, matched accept and installed its hits; with no walk in flight the old hits simply stayed. accept is the only other writer of hits, so nothing afterwards corrected either case. The generation now advances whether or not a walk is issued, the empty branch clears the hits through CrossSearch::clear_hits, and it asks for a redraw when it actually dropped something -- start_scan runs after the frame was presented, so the stale list is already on screen. The debounce decision moves out of draw into scan_due, which returns Idle, Start or WakeAt and is now under test. The Start arm carries no instant, so starting a walk cannot also register a wake-up. Co-Authored-By: Claude Opus 5 * feat: put the search box in the left pane and list the hits there Nothing on this branch had run yet: with no caller for the key, across was never Some, so the spawn, the 150ms debounce, the wake-up and the Scanned arm had never executed once. ⌘⇧F now opens the box, the box is drawn in the header band whether or not it holds focus, and typing in it queues a scan. While the word is non-blank the session list is replaced by the hits. A hit takes two lines: the first is the ordinary row -- state dot, name, ⌘ label -- and the second is the matched text, trimmed around the match with fit_center so the word itself cannot be the part that falls off, with search_hit laid under it. A blank word is not a zero-hit list; it is the session list again, and showing_hits decides that by the same rule start_scan uses to refuse to walk. Hit.id is looked up by id, never by position, because the session may have exited while the walk ran; such a row still shows its text. Hits lag the typed word by one walk, so an empty list says "searching…" while one is queued and "no match" only once none is. The result list takes no reordering and no drag, and the layout reserves no third line. The draw path reads nothing but the hits already in hand -- no scan, no Term lock, no work proportional to the scrollback. draw_terminal no longer claims cursor_px while the box has it, so the IME candidate window follows the box. TERMIT_PROBE_OVERLAY=across fills in four synthetic hits -- long, multi-byte, a title hit, and one naming a session that does not exist -- so --probe can show the list without a window. Co-Authored-By: Claude Opus 5 * fix: say searching while a scan runs, keep the box inside its bounds, allow paste - scan_note tells a running scan from a finished empty one - a note is always drawn when no hit row fits - preedit in the search box is clipped to the box and uses its colour - clicking the box or a hit no longer schedules a state save - Cmd+V pastes into the query with newlines turned into spaces - HitRow.closed replaces the exit = Some(0) stand-in Co-Authored-By: Claude Opus 5 * fix: keep the tail of a long preedit visible in the search box - clip the preedit with fit_head so the end being typed stays - split_box_width reserves MIN_PREEDIT for the preedit while composing - the probe preedit is now long enough to overflow the box Co-Authored-By: Claude Opus 5 * fix: let the preedit use all the room the query left unused The reserved minimum applies to the query budget only; the preedit now measures its room from where the query actually ended. Co-Authored-By: Claude Opus 5 * feat: jump to a hit by its serial and fall back to find Co-Authored-By: Claude Opus 5 * fix: leave the viewport alone when a hit's line has fallen out of history Pull the landing decision into a pure function, build the needle with scan::needle so the recheck matches what the scan matched, and recheck the row with the scan's own predicate. Co-Authored-By: Claude Opus 5 * docs: measure the scan and record the alt-screen hole Add a 14 x 10000 line scan section to --bench (about 49ms median, worst case: no early stop), document it as performance.md 7.15 with the static argument for why frames are not dropped, mark the live frame-log check as not yet done, and note the alt-screen hole and the new key in the README. Co-Authored-By: Claude Opus 5 * docs: tighten three claims in the scan notes State why the bench word appears once per session, narrow the draw-path claim to the draw functions (start_scan is reached at the end of a frame but only spawns the worker), count chunks per session, and name only vim and less in the README. Co-Authored-By: Claude Opus 5 * fix: mark the tick where the read starts writing `serial = pushed + line` and `line = 0` is the top of the screen, not the cursor. Marking a read at the pushed total it started with therefore claimed every row from the top of the screen downwards, painting up to `screen_lines - 1` rows that an earlier read had written with this read's time. An agent printing one line twenty minutes later gave its previous answer's last 46 rows a fresh timestamp, and those rows are the ones the user is looking for. `Counting` now records the cursor line it started on, the reader takes it from the first `Counting` of the read, and the tick lands on `before + start_line` — the first row this read actually writes. Co-Authored-By: Claude Opus 5 * fix: publish the pushed total under the term lock The reader dropped the `term` guard and only then stored the pushed total, so a scanner that took the lock in that gap saw an advanced grid with a stale total. That session's serials came out one read low — thousands of lines with a 64KiB read — `time_of` returned a far older tick, and the rows the user had just watched scroll past sank down the list or fell off the truncation. The `Enter` path re-checks the row and degrades safely; the ordering path has no such check. The store is an atomic, so moving it inside the guard adds no lock and keeps the reader's `term` -> `ticks` order intact. `scan.rs`'s claim that the total is read under the grid's own lock is now true on the writing side too. Co-Authored-By: Claude Opus 5 * fix: hand a case-insensitive pattern from a hit to the screen search The cross-session scan lowercases unconditionally, but `RegexSearch::new` is smart-case. Opening a hit put the typed word straight into `⌘F`, so a word with any uppercase letter in it — `SES`, `AWS`, `TODO`, `Error` — that had matched a lowercase row landed on the row and then highlighted nothing, and `find_step` either jumped to an unrelated older match or did not move. The alternate-screen path, whose only job is handing the word over, broke the same way. `ScreenSearch` grows an `ignore_case` flag that prefixes `(?i)` after the escaping, set only on the hand-off from `Enter`. `⌘F`'s own smart-case is untouched, and `query` keeps the typed characters so the bar reads the same. Co-Authored-By: Claude Opus 5 * fix: keep the counting when a read clears the scrollback A read that pushed lines and then emitted ED 3 (`\x1b[3J`) threw its whole count away: the history difference came out zero, `after >= cap` was false, and `finish()` returned the difference instead of the thousands of lines the per-line path had hooked. `clear` emits ED 3 whenever terminfo carries `E3`, so this is a daily command. The damage is bounded — the lines are gone and `open_hit`'s row re-check catches a stale serial — but the count itself has a hole, and every later serial in that session comes out low. The shrink test `after < before` alone does not see it: alacritty clears the history only when it is non-empty, so a read that pushes first and clears after leaves both sides at zero. `Counting` therefore marks the discard where it happens, and `finish()` takes the hooked count on either signal. RIS is left on the general shrink test and noted as a hole. Also records the ED 3 and over-the-cap cases in the module's hole list, which only named wrapping, DECSTBM and `resize`. Co-Authored-By: Claude Opus 5 * refactor: make the tick lookups total `Ticks::time_of` and `Ticks::newest` both fall back to the session's creation time, so neither can return `None`. Handing back an `Option` anyway forced a guarded ordering in `newest_first` and a test for a state no scan can produce, and left `Hit.at` readable as either "not a line" or "no time could be found". Both now return `Instant`. `Hit.at` keeps its `Option` with one meaning: `None` iff the hit is a name, title or branch. Co-Authored-By: Claude Opus 5 * refactor: enter the cross-session search through one door `⌘⇧F` and clicking the search box each set `across` up with different preconditions: the key path cleared `picker`, `search` and `find` and opened the pane, the click path cleared only `find`. The two are now one `enter_across`, so neither can be fixed alone. The divergence was not reachable in practice — `on_click` closes `picker`, `search` and `rename` and returns before it ever reaches the search box — but it only held because of an ordering two functions apart. Co-Authored-By: Claude Opus 5 * docs: record the wrap hole, rename a test, reuse the scan's needle Three small ones from the branch review: - The scan reads one grid row at a time and ignores `Flags::WRAPLINE`, so a word broken across a wrap is invisible to it while `⌘F`, which walks the grid as a stream, finds it. Not a spec violation and not worth joining rows for — stitching them raises the question of which row a hit's byte offset belongs to — but it is the second surprise a user meets, so it goes next to the alternate-screen hole in the README. - `全体の上限で止まる` promised an early exit that does not exist: `scan` collects each session's allowance and then truncates. The per-session limit is what bounds the work. Renamed to say truncate. - `probe.rs` lowercased with `str::to_lowercase`, which is the exact mismatch `scan::needle` exists to prevent (final sigma). It calls `needle` now. Co-Authored-By: Claude Opus 5 * test: cover the un-flagged history shrink, and fix two stale names Three small ones from the re-review: - `初期化で履歴が縮んでも数えは残る` covers `finish()`'s `after < before` arm, which until now no test touched: the ED 3 test reaches it but also sets `dropped`, so it passes with the arm deleted. A read that pushes three lines and then resets the terminal drops the history without setting the flag, so the shrink is the only signal — 9 with the arm, 6 without. That arm is all that stands between an un-flagged shrink and a pushed total losing thousands of lines, which is item 2's failure mode arriving by another door. - `間の空いた出力は前の出力の時刻にならない` still said the tick goes in at the pushed total as read, which `mark_read`'s own doc now contradicts. The test is right — it drives `push` directly — so the sentence now says so and points at the read-level test for the rule. - `旗を立てなければ_smart_case_のままである` carried Latin identifiers into a test name; this project writes them as whole Japanese sentences. Co-Authored-By: Claude Opus 5 * docs: measure what the line counting costs on the read path `Counting` sits between the parser and the `Term`, so every session's reads go through it whether anyone ever searches or not, and the numbers in 7.13 predate it. The first review ran `--throughput` three times and saw `scrolling` at 202.6 / 196.5 / 187.7ms against a recorded 179ms, but `light_cells` swung 114-136ms over the same three runs, so nothing could be concluded. Measured as an A/B instead: `main` in a second worktree, both release builds, the two binaries run alternately rather than one after the other, 11 rounds twice over, medians and a per-round paired comparison. The cost is per line feed and nothing per cell, which explains the shape of the table. `scrolling` is 16MiB of bare newlines — 16.8M feeds — and is slower in 22 of 22 pairs by 21.5ms, three times the paired spread: about 1.3ns a feed, four cycles, which matches what `at_bottom()` does. That is the only load the per-feed figure comes from. `scrolling_region` feeds the same count from the bottom row, so its per-feed work should be identical, yet its delta is 7ms smaller — that gap sits inside the paired spread and is recorded as unresolved rather than explained away. Loads that write cells show no difference; `light_cells` and `medium_cells` lean slower but their spread swallows the magnitude, and that is said plainly rather than turned into a number. 7.13's table now points at 7.16 instead of reading as current. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- README.md | 15 + docs/performance.md | 139 +++++ src/bench.rs | 108 +++- src/input.rs | 76 ++- src/lines.rs | 822 +++++++++++++++++++++++++++ src/main.rs | 1304 +++++++++++++++++++++++++++++++++++++++++-- src/probe.rs | 59 +- src/pty.rs | 72 ++- src/render.rs | 89 ++- src/scan.rs | 684 +++++++++++++++++++++++ src/search.rs | 109 +++- src/secret.rs | 9 +- src/session.rs | 9 + src/term.rs | 5 + src/throughput.rs | 13 +- 15 files changed, 3442 insertions(+), 71 deletions(-) create mode 100644 src/lines.rs create mode 100644 src/scan.rs diff --git a/README.md b/README.md index f7d62f2..42c9f9c 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ takes nothing, because neither shells nor agents use Cmd. | `^R` | Search command history | | `⌘N` / `⌘D` / `⌘E` | New / fork / fork with a chosen profile | | `⌘F` | Find in the screen and scrollback | +| `⌘⇧F` | Search every session's screen and scrollback from the left pane; `Enter` jumps to the hit | | `⌘I` (or `⌘⇧R`) | Rename the session | | `⌘K` | Clear the screen and scrollback | | `⌘[` / `⌘]` | Select the previous / next session | @@ -132,6 +133,20 @@ takes nothing, because neither shells nor agents use Cmd. Everything else goes to the child process untouched. +**Searching across sessions** (`⌘⇧F`) walks every session on a worker thread, +so typing never waits on it. It has one hole: a session sitting in the +alternate screen (`vim`, `less`, or any other full-screen program) can only be +searched over the screen it is showing right now. Its real scrollback is parked +in alacritty's `inactive_grid`, and nothing outside the crate can read it. The +history comes back to the search when the program exits. A hit on such a +screen switches to the session and hands the word to `⌘F`; it cannot jump to +the line. + +It reads one grid row at a time, so a word broken across a wrap — the line ran +past the right edge and continued on the next row — is invisible to it. `⌘F` +walks the grid as a stream and does find such a word, so the same search finds +it once you are in the session. + **The dot on a row** is filled while the session is alive and hollow once it has ended. Its colour is the session's state: diff --git a/docs/performance.md b/docs/performance.md index 7184ab5..870abda 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -521,6 +521,9 @@ alacritty の [vtebench](https://github.com/alacritty/vtebench) は端末の中 163 桁 x 47 行、履歴 10,000 行、1 種類あたり 16MiB。 +次の表は `Counting`(行の数え上げ)が読みの経路に入る**前**の数字である。 +入ったあとの測り直しは 7.16 にある。 + | 負荷 | 合計 | 走査 | 解釈 | 速さ | |---|---|---|---|---| | light_cells(短い行) | 153ms | 0.3ms | 153ms | 104 MiB/s | @@ -578,6 +581,141 @@ glyphon の `prepare` は `impl IntoIterator` を取る。 13〜16% 減。1 行に収まる変更である。 +## 7.15 横断検索の走査を測った(2026-10-29 追記) + +`⌘⇧F` は全セッションの画面と履歴を走る。仕様 §10 は「先に測る」を求めているので、 +`--bench` に走査の節を足した。 + +**測り方。** 14 本の `Term` を、シェルを起動せずに直接組み立てる(14 本ぶんの本物の出力は遅すぎる)。 +1 本は 80 桁 x 24 行、履歴 10000 行を満たしてある。行は 80 桁に収まり、本ごと・行ごとに中身が違う。 +語は 1 本に 1 つだけ置いた。`Redactor` は既定の式を入れてある。 +`scan::scan` を `Limits::default()`(1 本 20 件、全体 200 件、chunk 256)で呼び、1 回の所要時間を測る。 +最初の 2 回を捨て、15 回の中央と p90 を出す。1 回が約 50ms かかるので、フレームの測定(20 回)より少なくした。 + +**数字。** M4、macOS 26.5.1、`--release`。3 回走らせた。 + +| 走り | 中央 | p90 | +|---|---|---| +| 1 | 49.34ms | 50.35ms | +| 2 | 49.04ms | 50.16ms | +| 3 | 48.41ms | 49.47ms | + +14 本 x 10000 行 = 14 万行を約 49ms で歩く。1 行あたり約 0.35µs である。 + +**この数字は最悪の場合である。** 当たりは 14 件しかなく、全体の上限 200 に届かないので、走査は早じまいせず +全部の行を歩く。普通は当たりが多く、1 本 20 件に達した本は、そこで遡りを止める。 +語が全く当たらない場合も同じ費用になる。当たった行だけ `Redactor` を通すので、 +当たりが多いほど 1 行の費用は上がるが、採用は 200 件までに収まる。 + +**フレームは落とさないはずである。ただし実機では未確認。** 約 49ms は 1 フレーム(16.7ms)より長い。 +だから走査は別スレッドで走る。落とさない理由は、コードに次の 3 つがあることである。 + +1. **スレッドへ渡すのは `Arc` の複製と短い文字列だけ。** `start_scan`(`src/main.rs:2270`)が組む `Target` は、 + `term`・`pushed`・`ticks` の `Arc` を複製し、名前・題名・枝の文字列と語を複製する(`src/main.rs:2295-2310`)。 + `Redactor` も `Arc` の複製である(`src/main.rs:2310`)。グリッドは複製しない。 + スレッドを作るのは `std::thread::Builder::spawn` の 1 回で、描画の経路に走査の中身は入らない(`src/main.rs:2311`)。 +2. **描画の関数は走査も `Term` の錠も呼ばない。** 描画は結果の `Vec`(最大 200 件)のうち、 + 窓に見える数件だけを組む(`across_blocks`、`src/main.rs:2732-2750`)。 + `draw_across_hits`(`src/main.rs:3083`)から `draw_sidebar`(`src/main.rs:3201`)の範囲に、 + `scan::scan` の呼び出しも `term.lock()` も無い。履歴の長さに比例する仕事は無い。 + `start_scan` はイベントループがフレームの最後に呼ぶ(`src/main.rs:2454`)が、することはスレッドへ仕事を渡すだけで、1 がその費用を述べている。 +3. **走査は `Limits::chunk` 行ごとに `Term` の錠を放す。** `scan_lines`(`src/scan.rs:214`)は、 + 錠を取る `let term = target.term.lock()`(`src/scan.rs:242`)を塊ごとの内側の区画に入れ、 + `walked < chunk` で歩く行数を切る(`src/scan.rs:274`)。区画を抜けると錠が放れ、 + `ticks` はその後で取る(`src/scan.rs:300`)。1 本の履歴を取りっぱなしで走ることは無い。 + 1 塊は 256 行で、塊は本ごとに切るので、全体で 14 x ceil(10024 / 256) = 560、約 550 塊になる。約 49ms を約 550 で割ると、1 塊は約 90µs になる。 + これは割り算の見積もりで、錠の保持時間を直接測ったものではない。 + +**未了:実際のウィンドウでフレームの記録を見る確認。** この節を書いたのは窓も画面も無い環境で、 +`TERMIT_FRAME_LOG` を実機で見ていない。上の 3 つはコードを読んだ論証であり、 +「フレームが落ちなかった」という観測ではない。閉じるには、人が次を実行する。 + +```sh +RUST_LOG=info TERMIT_FRAME_LOG=1 cargo run --release +``` + +セッションを数本開き、それぞれに数千行を出してから `⌘⇧F` を押し、語を打って検索する。 +走査が走っているあいだの毎フレームの記録に、落ちたフレームが無いことを見る。 +あわせて、検索中も各セッションの出力が止まらないことを目で確かめる。 + +## 7.16 行の数え上げが読みの経路に足した費用を測った(2026-10-29 追記) + +`Counting`(`src/lines.rs`)は、横断検索が「新しい順」を出すために履歴へ押し出した行を数える。 +置いた場所がパーサと `Term` のあいだなので、**誰も検索しなくても全セッションの読みが必ず通る**。 +7.13 の数字はこれが入る前のものなので、測り直した。 + +**測り直した理由。** 最初の査読は `--throughput` を 3 回走らせて `scrolling` が +202.6 / 196.5 / 187.7ms(7.13 の記録は 179ms)になることを見たが、同じ 3 回で +`light_cells` が 114〜136ms に振れていた。振れが差を飲んでしまい、 +「遅くなった」とも「変わらない」とも言えなかった。記録が無いことが問題で、 +遅くなったことが分かっていたわけではない。 + +**測り方。** 同じ機械・同じ条件で比べるため、`main` を別の worktree に出し、両方を +`--release` で建てた。`--throughput` を 2 本のバイナリで**交互に**走らせる +(片方を全部走らせてから他方、ではない。熱のずれが片側に乗らないようにする)。 +11 周を 2 セット、計 22 組。組ごとの差を取るので、周のあいだの機械の状態は差に乗らない。 +`main` 側の `throughput.rs` は `parser.advance(&mut term, ..)`、枝側は +`Counting` で包む。harness の違いはそこだけである。 + +**数字。** M4、macOS 26.5.1、`--release`、22 組の中央値。 +「遅い組」は、枝のほうが遅かった組の数である。 + +| 負荷 | `main` | この枝 | 差 | 割合 | 遅い組 | 組ごとの差の四分位範囲 | +|---|---|---|---|---|---|---| +| light_cells(短い行) | 110.7ms | 117.2ms | +6.5ms | +5.9% | 17/22 | 14.6ms | +| medium_cells(半幅) | 97.6ms | 101.3ms | +3.7ms | +3.8% | 18/22 | 6.4ms | +| dense_cells(コマごとに色) | 60.2ms | 58.6ms | -1.6ms | -2.7% | 2/22 | 1.5ms | +| scrolling(送りだけ) | 181.8ms | 203.3ms | **+21.5ms** | **+11.8%** | **22/22** | 7.2ms | +| scrolling_region(領域を切って送り) | 644.5ms | 659.0ms | +14.5ms | +2.2% | **22/22** | 10.0ms | +| cursor_motion(飛ばして 1 字) | 67.9ms | 67.0ms | -0.9ms | -1.3% | 0/22 | 1.1ms | +| unicode(多バイト) | 72.2ms | 70.8ms | -1.4ms | -2.0% | 3/22 | 2.1ms | +| sync_cells(DECSET 2026 で囲む) | 92.2ms | 93.9ms | +1.7ms | +1.8% | 13/22 | 4.6ms | +| osc_heavy(シェル統合の通知が毎行) | 152.8ms | 150.6ms | -2.2ms | -1.4% | 6/22 | 4.8ms | + +**費用は「送り 1 回あたり」で、コマには付かない。** これが表の形をそのまま説明する。 + +`Counting::linefeed` は送りごとに `at_bottom()` を呼ぶ。見るのはカーソル行と画面行数の 2 つで、 +比べて `hooked` を 1 増やすだけである。いっぽう `input`(コマに字を置く)は `forward!` が +`#[inline]` でそのまま渡すので、何も足さない。だから**送りの多い負荷だけが遅くなる**。 + +`scrolling` は 16MiB が全部素の `\n` で、送りが 16,777,216 回ある。 ++21.5ms をその数で割ると **1 回あたり約 1.3ns**、3GHz なら 4 サイクルほどで、 +上に書いた仕事量とちょうど合う。 + +`scrolling_region` は DECSTBM を張ってからカーソルを最下行へ置くので、 +送りの数(約 16M 回)も 1 回あたりの仕事(`at_bottom()` は最初の送りから真)も +`scrolling` と同じはずである。それなのに差は 14.5ms で、7ms 小さい。 +この 7ms は分解していない。組ごとの差の四分位範囲 10.0ms の中に収まるので、 +領域送りの経路との当たり方か、測りの振れかを分けられなかった。 +割合が +2.2% なのは、下地が 3.5 倍遅いからである。 + +`dense_cells`・`cursor_motion`・`unicode`・`osc_heavy` は 1 行が長く、送りの数が桁違いに少ないので +差は出ない(むしろ少し速いが、符号が揃っていないので配置の揺れである)。 + +`light_cells`(1 行 10 バイト、送り約 168 万回)と `medium_cells`(1 行 83 バイト、約 20 万回)に +1.3ns を当てると 2.2ms と 0.3ms になる。測った差(+6.5ms・+3.7ms)はそれより大きいが、 +どちらも組ごとの振れの中なので、この 2 つから 1 回あたりの費用は出せない。 +1 回あたりの数字が出せるのは `scrolling` だけである。 + +**言えること。** + +- **`scrolling` の +11.8% は本物である。** 22 組すべてで枝が遅く、差 21.5ms は + 組ごとの差の四分位範囲 7.2ms の 3 倍ある。89 MiB/s が 79 MiB/s になった。 +- **ただしこれは最悪の形で、普通の出力の形ではない。** 1 バイトが 1 回の送りという負荷である。 + 字を書く負荷(`dense_cells` 以下)では差が出ない。 +- **`scrolling_region` も 22 組すべてで遅い。** 送りの数も 1 回あたりの仕事も `scrolling` と + 同じはずなのに、差は 14.5ms で 7ms 小さい。その 7ms は分解できていない(上に書いた)。 +- **`light_cells` と `medium_cells` は向きだけ言える。** 17/22・18/22 なので遅くなる向きは + たしかだが、差(+6.5ms・+3.7ms)が組ごとの差の四分位範囲(14.6ms・6.4ms)に収まっていない/ + 同程度なので、**大きさは決まっていない**。最初の査読が振れに悩んだのはこの負荷である。 +- 1 台の機械の数字である。 + +**受け入れる理由。** 79 MiB/s は、pty が実際に届けてくる速さよりずっと速い。 +7.13 が書いているとおり、ここで測っているのは「パーサとグリッドが受け取れる速さ」であって、 +重さの原因が読み取りだと分かっているわけではない。送り 4 サイクルを削るには、 +数えを 1 文字ごとの経路へ入れるか(7.13 が `input` の 1 割として却下した道)、 +行に時刻を持たせる別の仕組みが要る。横断検索の「新しい順」が要る以上、この 4 サイクルは払う。 + ## 8. 参考にした資料 手法ごとの採否は [references/performance-techniques.md](references/performance-techniques.md) にまとめた。 @@ -604,3 +742,4 @@ glyphon の `prepare` は `impl IntoIterator` を取る。 | 常駐メモリ(1 ペイン) | 100MB 以内 | 84.7MB | | 起動から子プロセスの生成まで | 200ms 以内 | 224ms | | バイナリ | 単一ファイル | 9.4MB | +| 横断検索の走査(14 本 x 10000 行) | 別スレッド | 49ms(実機のフレーム確認は未了、7.15) | diff --git a/src/bench.rs b/src/bench.rs index 5dbc015..b7d9fb6 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -3,14 +3,32 @@ //! 遅さの原因を「描画の指示を組み立てる費用(CPU)」と //! 「GPU へ投げて待つ費用」に切り分けるために使う。 -use std::time::Duration; +use std::sync::atomic::AtomicU64; +use std::sync::mpsc::channel; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use alacritty_terminal::event::WindowSize; +use alacritty_terminal::sync::FairMutex; +use alacritty_terminal::vte::ansi::{Processor, StdSyncHandler}; + +use crate::config::ScreenConfig; +use crate::lines::Ticks; use crate::render::Renderer; +use crate::scan::{self, Limits, Target}; +use crate::secret::Redactor; +use crate::term::{EventProxy, TermSize, UiSender}; use crate::theme::Theme; const FRAMES: usize = 20; const WARMUP: usize = 4; +/// 走査の測定回数。1 回が数十 ms から数百 ms かかるので、フレームの `FRAMES` より +/// 少なくする。p90 は 15 回なら上から 2 番目の値になる。 +const SCAN_RUNS: usize = 15; +/// 捨てる最初の回数。ページと分岐予測を温める。 +const SCAN_WARMUP: usize = 2; + pub fn run() { let theme = Theme::default(); let sizes = [(1280u32, 800u32), (1920, 1200)]; @@ -62,6 +80,7 @@ pub fn run() { } } row_estimate(); + scan_cost(); println!("\n60fps の予算は 16.7ms。入力から表示までの体感は、これに vsync 待ちが加わる。"); } @@ -117,3 +136,90 @@ fn row_estimate() { bm + pm ); } + +/// 横断検索の走査(`scan::scan`)の費用。仕様 §10 の「先に測る」。 +/// +/// 14 本 × 10000 行の履歴を、実際の `Term` に流して作る。シェルは起動しない +/// (14 本ぶんの本物の出力は遅すぎる)。行の中身は毎行変え、当たりは 1 本に 1 つだけ +/// 置く。当たりは上限(全体 200)に遠く届かないので、走査は早じまいせず +/// **全部の行を歩く**。これは最悪の場合であり、普通の場合ではない。 +fn scan_cost() { + const SESSIONS: usize = 14; + const HISTORY: usize = 10_000; + const COLS: usize = 80; + const LINES: usize = 24; + // 1 本に 1 行だけ現れる語。当たりが全体 200 件の上限に届かず、早じまいせずに全部歩く最悪の場合が測れる。 + const WORD: &str = "zebraquux"; + + let targets: Vec = (0..SESSIONS) + .map(|s| { + let (pty_tx, _pty_rx) = channel(); + let (ui_tx, _ui_rx) = channel(); + let ws = Arc::new(FairMutex::new(WindowSize { + num_lines: LINES as u16, + num_cols: COLS as u16, + cell_width: 8, + cell_height: 16, + })); + let proxy = EventProxy::new(s as u32, pty_tx, UiSender::Channel(ui_tx), ws); + let mut term = crate::term::new_term(TermSize::new(COLS, LINES), HISTORY, proxy); + let mut parser: Processor = Processor::new(); + // 画面の行数ぶん余分に流し、履歴をちょうど満たす。 + let total = HISTORY + LINES; + let mut play = String::new(); + // 1 行は 80 桁に収める。折り返すと履歴の行数が減り、当たりが履歴の外へ出る。 + for i in 0..total { + // 本ごと・行ごとに違う文字列にする。 + let mix = ((i as u64).wrapping_mul(2_654_435_761) + ^ (s as u64).wrapping_mul(40_503)) + & 0xffff_ffff; + if i == 5_000 + s { + play.push_str(&format!("[{s:02}] {i:05} found {WORD} {mix:x}\r\n")); + } else { + play.push_str(&format!( + "[{s:02}] {i:05} $ cargo build -p crate{} {mix:x} warning: unused\r\n", + i % 97 + )); + } + } + parser.advance(&mut term, play.as_bytes()); + Target { + id: s as u32, + term: Arc::new(FairMutex::new(term)), + pushed: Arc::new(AtomicU64::new(0)), + ticks: Arc::new(Mutex::new(Ticks::new())), + name: format!("session-{s}"), + title: None, + branch: None, + } + }) + .collect(); + let redactor = + Redactor::new(&ScreenConfig::default().redact).expect("既定の式は組み立てられる"); + let limits = Limits::default(); + + println!( + "\n=== 横断検索の走査({SESSIONS} 本 x {HISTORY} 行 = {} 行、当たりは 1 本に 1 つ)===", + SESSIONS * HISTORY + ); + let mut times = Vec::new(); + let mut found = 0usize; + for run in 0..SCAN_WARMUP + SCAN_RUNS { + let t0 = Instant::now(); + let hits = scan::scan(&targets, WORD, &redactor, limits); + let d = t0.elapsed(); + found = hits.len(); + if run >= SCAN_WARMUP { + times.push(d); + } + } + // 当たりが 1 本に 1 つ見つからなければ、早じまいか行の取り違えで、測りたいものを測っていない。 + if found != SESSIONS { + println!(" 警告: 当たりが {found} 件で {SESSIONS} 件にならない。この数字は使えない"); + } + let (m, p90) = stats(×); + println!( + " 走査 当たり {found:>3} 件 1 回あたり 中央 {m:7.2}ms p90 {p90:7.2}ms ({SCAN_RUNS} 回, 捨て {SCAN_WARMUP} 回, chunk {})", + limits.chunk + ); +} diff --git a/src/input.rs b/src/input.rs index 06bf9fe..526ec0c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -26,6 +26,8 @@ pub enum Action { FindInScreen, /// セッションに名前を付ける。 RenameSession, + /// 全セッションの出力を探す。 + FindAcrossSessions, FontBigger, FontSmaller, ScrollUp, @@ -116,10 +118,14 @@ fn action_from_char(key: &Key, mods: ModifiersState) -> Option { _ => None, }; } - // Cmd 側。Shift を併用する組み合わせは ⌘⇧R だけに限る。 + // Cmd 側。Shift を併用する組み合わせは ⌘⇧R と ⌘⇧F だけに限る。 if mods.super_key() && !mods.control_key() && !mods.alt_key() { if mods.shift_key() { - return (c.as_str() == "r").then_some(Action::RenameSession); + return match c.as_str() { + "f" => Some(Action::FindAcrossSessions), + "r" => Some(Action::RenameSession), + _ => None, + }; } return match c.as_str() { "n" => Some(Action::NewSession), @@ -171,6 +177,20 @@ fn action_from_physical(physical: PhysicalKey, mods: ModifiersState) -> Option Some(Action::FindAcrossSessions), + KeyCode::KeyR => Some(Action::RenameSession), + KeyCode::BracketLeft => Some(Action::SelectPrev), + KeyCode::BracketRight => Some(Action::SelectNext), + KeyCode::Equal => Some(Action::FontBigger), + KeyCode::Minus => Some(Action::FontSmaller), + _ => None, + }; + } let command = match code { KeyCode::KeyN => Some(Action::NewSession), KeyCode::KeyD => Some(Action::Fork), @@ -319,6 +339,58 @@ mod tests { } const NONE: ModifiersState = ModifiersState::empty(); + #[test] + fn コマンドとシフトの記号キーは物理キー側で通る() { + let cs = ModifiersState::SUPER | ModifiersState::SHIFT; + let shift = named(NamedKey::Shift); + for (code, want) in [ + (KeyCode::Equal, Action::FontBigger), + (KeyCode::Minus, Action::FontSmaller), + (KeyCode::BracketLeft, Action::SelectPrev), + (KeyCode::BracketRight, Action::SelectNext), + ] { + assert_eq!(action_for(&shift, PhysicalKey::Code(code), cs), Some(want)); + } + } + + #[test] + fn コマンドとシフトのエフは横断検索になる() { + let cs = ModifiersState::SUPER | ModifiersState::SHIFT; + // 文字側(通常の配列)。 + assert_eq!( + action_for(&ch("f"), PhysicalKey::Code(KeyCode::KeyF), cs), + Some(Action::FindAcrossSessions) + ); + // 物理キー側(文字が取れない配列)。 + assert_eq!( + action_for( + &named(NamedKey::Shift), + PhysicalKey::Code(KeyCode::KeyF), + cs + ), + Some(Action::FindAcrossSessions) + ); + } + + #[test] + fn コマンドだけのエフは画面内検索のまま() { + let cmd = ModifiersState::SUPER; + assert_eq!( + action_for(&ch("f"), PhysicalKey::Code(KeyCode::KeyF), cmd), + Some(Action::FindInScreen) + ); + } + + #[test] + fn コマンドとシフトはエフとアールだけ受ける() { + let cs = ModifiersState::SUPER | ModifiersState::SHIFT; + let phys = PhysicalKey::Code(KeyCode::KeyR); + assert_eq!(action_for(&ch("r"), phys, cs), Some(Action::RenameSession)); + // ⌘⇧N は、いまは物理キー側で NewSession に落ちている。落ちないこと。 + let n = PhysicalKey::Code(KeyCode::KeyN); + assert_eq!(action_for(&named(NamedKey::Shift), n, cs), None); + } + #[test] fn 印字可能な文字をそのまま送る() { assert_eq!( diff --git a/src/lines.rs b/src/lines.rs new file mode 100644 index 0000000..f68a7f0 --- /dev/null +++ b/src/lines.rs @@ -0,0 +1,822 @@ +//! 行の通し番号。 +//! +//! 行から時刻を引くための土台である。`Grid::history_size()` はスクロールバックの +//! 上限で頭打ちになるので、1 時間前の行と今の行が同じ番号になってしまう。そこで +//! `Term` へ渡す前に `Handler` を包み、履歴へ押し出した行を自分で数える。 +//! +//! 画面上の行位置 `line` は、新しい行が来るたびに 1 ずつ小さくなる。押し出した +//! 行数 `pushed` を足せば、その行に一度しか付かない番号になる(`serial`)。 +//! 逆に、覚えておいた番号からいまの行位置も出せる(`line_of`)。 +//! +//! 番号を引く側(`serial`・`line_of`・`Ticks::time_of`)の読み手は、左ペインの +//! 全セッション検索(`scan.rs`)である。 + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use alacritty_terminal::grid::{Dimensions, GridCell}; +use alacritty_terminal::index::Line; +use alacritty_terminal::term::TermMode; +use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon; +use alacritty_terminal::vte::ansi::{ + Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes, + KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb, + ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode, +}; +use alacritty_terminal::Term; + +use crate::term::EventProxy; + +/// 行位置 `line` の通し番号。画面の最上段がちょうど `pushed` になる。 +pub fn serial(pushed: u64, line: i32) -> i64 { + pushed as i64 + i64::from(line) +} + +/// 通し番号 `serial` がいまどの行位置にあるか。履歴から押し出されていれば `None`。 +pub fn line_of(serial: i64, pushed: u64, history_size: usize) -> Option { + let line = serial - pushed as i64; + if line < -(history_size as i64) { + return None; + } + i32::try_from(line).ok() +} + +/// 覚えておく目盛りの数。 +/// +/// 積むのは行を押し出した読みだけで、それも 1 秒に 1 つまでなので、 +/// 68 分以上ぶんになる。プロンプトの描き直しのような押し出さない読みは +/// 枠を使わないので、静かな端末ではもっと長く覚えている。 +/// +/// 1 件は `(u64, Instant)` の 24 バイトで、セッションあたり約 96KB になる +/// (`size_of` で確かめた)。 +pub const TICKS: usize = 4096; + +/// 目盛りの間隔。仕様は 1 秒の分解能で足りるとしている。 +/// +/// 1 読みにつき 1 つ積むと、忙しい出力では数分で環を使い切ってしまう。 +const TICK_GAP: Duration = Duration::from_secs(1); + +/// 通し番号と時刻の対応。古いものから捨てる環である。 +#[derive(Debug)] +pub struct Ticks { + /// `(そのときまでに押し出した行数, 時刻)`。行数の昇順に並ぶ。 + marks: VecDeque<(u64, Instant)>, + /// この本を開いた時刻。目盛りが 1 つも無いあいだの答えになる。 + /// + /// 一度も行を押し出していない本(開いたばかり・静かな本・流れる前に代替画面へ + /// 入った本)でも、行から時刻が引けるようにする。引けないと、その行は + /// 全セッション検索で時刻の無い件になってしまう。 + /// + /// これを目盛りとして**積んではいけない**。積むと、最初に押し出した読みの目盛りが + /// 「同じ行数は積まない」で落ちて、その読みで出た行が本を開いた時刻になる。 + /// 20 分プロンプトで待ってから打った命令の出力が 20 分前の時刻になり、 + /// ただ喋り続けていた本より下に沈む。探している行はたいていそれである。 + created_at: Instant, +} + +impl Ticks { + /// 目盛りを 1 つも持たない環を作る。作った時刻だけを覚えておく。 + /// + /// 目盛りは行を押し出した読みが積む。環が空のあいだ `time_of` と `newest` は + /// 作った時刻を返すので、一度も押し出していない本の行にも時刻が付く。 + /// 最初に押し出した読みの目盛りは、環が空なので必ず積まれる。 + pub fn new() -> Self { + Self { + marks: VecDeque::new(), + created_at: Instant::now(), + } + } + + /// 1 回の読みを目盛りに残す。`before` は読む前、`after` は読んだ後の行数で、 + /// `start_line` は読み始めのカーソル行である。 + /// + /// 目盛り `(p, t)` は「番号 p から次の目盛りまでの行が t に出た」という意味なので、 + /// 打つ番号はこの読みが**作る最初の行**でなければならない。`line = 0` は画面の + /// 上端なので、`before` が指すのは読み始めに画面の上端に有る行 — ずっと前の読みが + /// 書いた行 — である。そこへ打つと、すでに画面に出ていた最大「画面行数 - 1」行が + /// この読みの時刻になってしまう。既定の窓なら 46 行ほどで、エージェントが 20 分後に + /// 1 行出すだけで、前の答えの末尾 46 行が新しい時刻を持つ。この読みが作る行は + /// カーソルの居る所から始まるので、`before + start_line` へ打つ。 + /// + /// 1 行も押し出していない読みには残すものが無い。それどころか、同じ行数の目盛りは + /// 積まないので、次に本当に押し出した読みの枠を取ってしまう。プロンプトの + /// 描き直しや打った字の echo がまさにそれで、`ls` の出力がその前の出力の時刻に + /// なってしまう。 + /// + /// カーソルを足すぶん、打つ番号が前の目盛りより小さくなりうる(本画面の UI が + /// その場で描き直してカーソルを上へ戻し、押し出した行がその戻りより少ないとき)。 + /// `push` の昇順の番はそれを捨てるので、その読みの行は次に押し出す読みまで + /// 前の時刻のままになる。`Counting` が既に抱えている「その場の描き直しは追えない」 + /// 穴と同じもので、並びの前提を崩すより落とすほうを採る。 + pub fn mark_read(&mut self, before: u64, after: u64, start_line: i32, at: Instant) { + if after == before { + return; + } + self.push(before + start_line.max(0) as u64, at); + } + + /// いまの行数と時刻を積む。 + /// + /// 行数が増えていないか、前の目盛りから 1 秒経っていなければ何もしない。 + /// 読み手は押し出した読みでしか呼ばないので、行数は必ず増えている。 + /// それでも見るのは、並びが昇順であることが `time_of` の前提だからである。 + fn push(&mut self, pushed: u64, at: Instant) { + if let Some(&(last, when)) = self.marks.back() { + if pushed <= last || at.saturating_duration_since(when) < TICK_GAP { + return; + } + } + if self.marks.len() == TICKS { + self.marks.pop_front(); + } + self.marks.push_back((pushed, at)); + } + + /// 通し番号 `serial` の行が現れた時刻。 + /// + /// 目盛りを捨てたぶんの時刻は分からない。残っている最古の時刻を返す。 + /// その行がそれより古いことは確かである。 + /// 目盛りが 1 つも無ければ、本を開いた時刻を返す。 + /// + /// どの行にも必ず時刻が付く。引けないことは無いので `Option` にしない。 + /// 包むと、呼ぶ側が「行でない」と「時刻が引けない」を取り違える。 + pub fn time_of(&self, serial: i64) -> Instant { + let after = self + .marks + .partition_point(|&(pushed, _)| (pushed as i64) <= serial); + self.marks + .get(after.saturating_sub(1)) + .map_or(self.created_at, |&(_, at)| at) + } + + /// いちばん新しい目盛りの時刻。 + /// + /// 代替画面の行は通し番号が引けない(`pushed` が進まないので、付けた番号は + /// 本画面の無関係な行を指す)。その行には、この本が最後に行を押し出した + /// 時刻を代わりに付ける。1 行も押し出していなければ、本を開いた時刻になる。 + pub fn newest(&self) -> Instant { + self.marks.back().map_or(self.created_at, |&(_, at)| at) + } +} + +impl Default for Ticks { + fn default() -> Self { + Self::new() + } +} + +/// 履歴へ押し出した行を数えながら `Term` へ中継する。 +/// +/// `Processor::advance` は `Handler` を実装するものを何でも受け取るので、 +/// `Term` の代わりにこれを渡せば、通った escape sequence を数えられる。 +/// +/// 数え方は 2 つあり、始めた時点で履歴が上限に達していたかで選ぶ。 +/// +/// 達していなければ、履歴行数の差がそのまま答えである。押し出した行が 1 行ずつ +/// 履歴を伸ばすので、どの道を通っても漏れない。履歴へ入らない送り(DECSTBM で +/// 範囲を絞った中のスクロール)を数えてしまうこともない。 +/// 達していれば差が出ないので、行ごとの道で数えたほうを答えにする。行ごとに +/// 数えるのは、改行・NEL・SU・行削除・画面消去(ED 2)・行末のタブである。 +/// 同じ読みがスクロールバックを捨てた(ED 3)ときも差が出ないので、そこでも +/// 行ごとの道を採る。 +/// +/// 上限に達してからは、数え漏らしと数え過ぎが残る。行ごとの道は画面の形しか +/// 見ていないからである。 +/// +/// - 行末の折り返しは `Term` が自分の `linefeed` を呼ぶので、こちらの `linefeed` +/// を通らない。上限に届くまでは差に出るので数えられるが、上限に達してからは +/// 数えられない。1 文字ごとに見れば数えられるが、それは `input` を 1 割ほど +/// 遅くするので採らない(仕様 §4 の非目標)。 +/// - DECSTBM で送り範囲を絞った本画面では、履歴へ入らない送りを数えてしまう。 +/// `at_bottom` は画面の最下行を見るが `Term::linefeed` が見るのは +/// `scroll_region.end` で、履歴が伸びるのは `region.start == 0` のときだけである。 +/// `scroll_up` は画面の高さで、`delete_lines` はカーソルが 0 行目かどうかで、 +/// `folds` は範囲を見ずに数える。範囲は `Term` の外から読めないので追えない。 +/// 同じ読みが上限を跨ぐか履歴を捨てるかしたときは、差のほうを採れないので、 +/// この多めに数えた `hooked` がそのまま答えになる。 +/// - ED 3 でスクロールバックを捨てた読みは、差がこの読みの押し出しを表さないので +/// 行ごとの道へ落ちる。消えた行はもう引かれないが、押し出した数を捨てると +/// その後の通し番号がそのぶん低く出てしまうので、数えたほうは残す。 +/// 履歴を捨てる道はほかに RIS(`\x1bc`)が有り、こちらは印を付けていない。 +/// 始めた時点の履歴が 0 でない限り差が縮むので捕まるが、同じ読みで押し出して +/// から RIS したときは数えを落とす。`reset` を打つ場面しかないので置いてある。 +/// - `Term::resize` の折り返し直しは行の位置を動かすので、大きさを変えると +/// 古い通し番号は少しずれる。 +pub struct Counting<'a> { + inner: &'a mut Term, + pushed: u64, + /// 始めた時点の履歴行数。代替画面にいたなら `None`。 + before: Option, + /// スクロールバックの上限(行)。履歴行数の差が出ない側にいるかの判定に使う。 + cap: usize, + /// 行ごとの道で数えた行数。 + hooked: u64, + /// 始めた時点のカーソル行。この読みが作る行はここから始まる。 + start_line: i32, + /// この読みのあいだにスクロールバックが捨てられた(ED 3)。 + /// + /// 履歴行数の差がこの読みの押し出しを表さなくなる。始めた時点の履歴が 0 でも + /// (この読みが押し出してから消した)差は 0 になるので、`after < before` では + /// 捕まらない。捨てた瞬間に印を付けておく。 + dropped: bool, +} + +impl<'a> Counting<'a> { + pub fn new(inner: &'a mut Term, pushed: u64, cap: usize) -> Self { + // 代替画面の履歴行数は本画面のものではない。差の起点にはしない。 + let before = + (!inner.mode().contains(TermMode::ALT_SCREEN)).then(|| inner.grid().history_size()); + // 本画面なら 0..画面行数 に収まる。`max(0)` は守りである。 + let start_line = inner.grid().cursor.point.line.0.max(0); + Self { + inner, + pushed, + before, + cap, + hooked: 0, + start_line, + dropped: false, + } + } + + /// 始めた時点のカーソル行。 + /// + /// この読みが作る行は、カーソルの居る所から始まる。`serial = pushed + line` で + /// `line = 0` は画面の上端なので、読む前の総数だけでは目盛りを 1 画面ぶん + /// 早く打ってしまう(`Ticks::mark_read`)。読み手は 1 回の読みで `Counting` を + /// 何度も作るので、その読みの**最初の**ものから取る。 + pub fn start_line(&self) -> i32 { + self.start_line + } + + /// 数え終えて、押し出した行の合計を返す。 + pub fn finish(self) -> u64 { + let counted = match self.before { + // すでに上限に達していれば、履歴は伸びない。行ごとに数えたほうを採る。 + Some(before) if before >= self.cap => self.hooked, + Some(before) if !self.alt() => { + let after = self.history(); + let delta = after.saturating_sub(before) as u64; + if self.dropped || after < before { + // スクロールバックが捨てられた(ED 3)か、ほかの理由で履歴が + // 縮んだ。どちらも差はこの読みの押し出しを表さないので、行ごとに + // 数えたほうを採る。消えた行はもう引かれないが、押し出した数を + // 捨てると、この読みより後の行の通し番号がそのぶん低く出てしまう。 + delta.max(self.hooked) + } else if after >= self.cap { + // この 1 回で上限に届いた。届いたあとの押し出しは差に出ない。 + // 1 回の読みは 64KB まで入るので、改行だけなら何千行にもなる。 + delta.max(self.hooked) + } else { + // 伸びる側にいるあいだは、差が正確である。 + delta + } + } + // 途中で画面が入れ替わっていれば、差は本画面のものではない。 + _ => self.hooked, + }; + self.pushed + counted + } + + /// 押し出しの起きうる呼び出しを、数えながら行う。 + /// + /// `lines` は押し出される行数。カーソルが動く前に数えるので、呼ぶ側で出す。 + fn counted(&mut self, lines: usize, call: impl FnOnce(&mut Term)) { + // 代替画面に履歴は無い。全画面の UI が流す行は数えない。 + if !self.alt() { + self.hooked += lines as u64; + } + call(self.inner); + } + + fn history(&self) -> usize { + self.inner.grid().history_size() + } + + fn alt(&self) -> bool { + self.inner.mode().contains(TermMode::ALT_SCREEN) + } + + /// 画面の最下行にいるか。そこで改行すると 1 行が押し出される。 + fn at_bottom(&self) -> bool { + let grid = self.inner.grid(); + grid.cursor.point.line.0 as usize + 1 == grid.screen_lines() + } + + /// いま折り返すと 1 行押し出される位置にいるか。 + fn folds(&self) -> bool { + self.inner.mode().contains(TermMode::LINE_WRAP) && self.at_bottom() + } + + /// 溜めてある折り返しが、次の書き込みで 1 行押し出すか。 + fn wrapping(&self) -> bool { + self.inner.grid().cursor.input_needs_wrap && self.folds() + } + + /// 画面に何か書いてある行数。ED 2 が履歴へ押し上げる行数になる。 + fn filled_lines(&self) -> usize { + let grid = self.inner.grid(); + (0..grid.screen_lines() as i32) + .rev() + .find(|&line| grid[Line(line)][..].iter().any(|cell| !cell.is_empty())) + .map_or(0, |line| line as usize + 1) + } +} + +/// `Handler` の残りをそのまま `Term` へ渡す。 +/// +/// 1 本でも書き漏らすと、その escape sequence は黙って捨てられる。 +macro_rules! forward { + ($( fn $name:ident ( $( $arg:ident : $ty:ty ),* ); )*) => { + $( + #[inline] + fn $name(&mut self $(, $arg: $ty)*) { + self.inner.$name($($arg),*) + } + )* + }; +} + +impl Handler for Counting<'_> { + fn put_tab(&mut self, count: u16) { + // 行末のタブは折り返しになる。 + let lines = usize::from(self.wrapping()); + self.counted(lines, |term| term.put_tab(count)); + } + + fn linefeed(&mut self) { + let lines = usize::from(self.at_bottom()); + self.counted(lines, |term| term.linefeed()); + } + + fn newline(&mut self) { + // NEL は改行と同じ道を通る。 + let lines = usize::from(self.at_bottom()); + self.counted(lines, |term| term.newline()); + } + + fn scroll_up(&mut self, lines: usize) { + let pushed = lines.min(self.inner.grid().screen_lines()); + self.counted(pushed, |term| term.scroll_up(lines)); + } + + fn delete_lines(&mut self, lines: usize) { + // 画面の先頭で消すと、下の行が上へ送られて履歴へ出る。 + let grid = self.inner.grid(); + let pushed = if grid.cursor.point.line.0 == 0 { + lines.min(grid.screen_lines()) + } else { + 0 + }; + self.counted(pushed, |term| term.delete_lines(lines)); + } + + fn clear_screen(&mut self, mode: ClearMode) { + // ED 2 は本画面では消さずに、書いてあるぶんを履歴へ押し上げる。 + let lines = if matches!(mode, ClearMode::All) && !self.alt() { + self.filled_lines() + } else { + 0 + }; + // ED 3 は履歴を丸ごと捨てる(`Term` は履歴が有るときだけ捨てる)。 + // 捨てられたら、この読みの数え上げは差からは出せない。 + if matches!(mode, ClearMode::Saved) && self.history() > 0 { + self.dropped = true; + } + self.counted(lines, |term| term.clear_screen(mode)); + } + + forward! { + fn set_title(title: Option); + fn set_cursor_style(style: Option); + fn set_cursor_shape(shape: CursorShape); + fn input(c: char); + fn goto(line: i32, col: usize); + fn goto_line(line: i32); + fn goto_col(col: usize); + fn insert_blank(count: usize); + fn move_up(lines: usize); + fn move_down(lines: usize); + fn identify_terminal(intermediate: Option); + fn device_status(arg: usize); + fn move_forward(col: usize); + fn move_backward(col: usize); + fn move_down_and_cr(row: usize); + fn move_up_and_cr(row: usize); + fn backspace(); + fn carriage_return(); + fn bell(); + fn substitute(); + fn set_horizontal_tabstop(); + fn scroll_down(lines: usize); + fn insert_blank_lines(lines: usize); + fn erase_chars(count: usize); + fn delete_chars(count: usize); + fn move_backward_tabs(count: u16); + fn move_forward_tabs(count: u16); + fn save_cursor_position(); + fn restore_cursor_position(); + fn clear_line(mode: LineClearMode); + fn clear_tabs(mode: TabulationClearMode); + fn set_tabs(interval: u16); + fn reset_state(); + fn reverse_index(); + fn terminal_attribute(attr: Attr); + fn set_mode(mode: Mode); + fn unset_mode(mode: Mode); + fn report_mode(mode: Mode); + fn set_private_mode(mode: PrivateMode); + fn unset_private_mode(mode: PrivateMode); + fn report_private_mode(mode: PrivateMode); + fn set_scrolling_region(top: usize, bottom: Option); + fn set_keypad_application_mode(); + fn unset_keypad_application_mode(); + fn set_active_charset(index: CharsetIndex); + fn configure_charset(index: CharsetIndex, charset: StandardCharset); + fn set_color(index: usize, color: Rgb); + fn dynamic_color_sequence(prefix: String, index: usize, terminator: &str); + fn reset_color(index: usize); + fn clipboard_store(kind: u8, bytes: &[u8]); + fn clipboard_load(kind: u8, terminator: &str); + fn decaln(); + fn push_title(); + fn pop_title(); + fn text_area_size_pixels(); + fn text_area_size_chars(); + fn set_hyperlink(link: Option); + fn set_mouse_cursor_icon(icon: CursorIcon); + fn report_keyboard_mode(); + fn push_keyboard_mode(mode: KeyboardModes); + fn pop_keyboard_modes(to_pop: u16); + fn set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior); + fn set_modify_other_keys(mode: ModifyOtherKeys); + fn report_modify_other_keys(); + fn set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::term::{TermSize, UiSender}; + use alacritty_terminal::event::WindowSize; + use alacritty_terminal::sync::FairMutex; + use alacritty_terminal::vte::ansi::{Processor, StdSyncHandler}; + use std::sync::mpsc::channel; + use std::sync::Arc; + use std::time::Duration; + + fn make_term(cols: usize, lines: usize, scrollback: usize) -> Term { + let (pty_tx, _pty_rx) = channel(); + let (ui_tx, _ui_rx) = channel(); + let size = TermSize::new(cols, lines); + let ws = Arc::new(FairMutex::new(WindowSize { + num_lines: lines as u16, + num_cols: cols as u16, + cell_width: 8, + cell_height: 16, + })); + let proxy = EventProxy::new(1, pty_tx, UiSender::Channel(ui_tx), ws); + crate::term::new_term(size, scrollback, proxy) + } + + /// 台本を 1 回の読みとして流し、数えた行数と、端末が抱えた履歴行数を返す。 + /// + /// 読み手は 64KB の器へ読み、OSC の終端でだけ区切る。改行の多い出力なら + /// 1 回の読みに何千行も入るので、1 回で流すのが本物に近い。 + /// 上限に届くまでは履歴行数が答え合わせになる。 + fn feed(cols: usize, lines: usize, scrollback: usize, script: &[u8]) -> (u64, usize) { + feed_reads(cols, lines, scrollback, &[script]) + } + + /// 台本を行ごとの読みに分けて流す。 + fn feed_by_line(cols: usize, lines: usize, scrollback: usize, script: &[u8]) -> (u64, usize) { + let reads: Vec<&[u8]> = script.split_inclusive(|&b| b == b'\n').collect(); + feed_reads(cols, lines, scrollback, &reads) + } + + /// 読みの列を、読み手と同じように 1 回ずつ流す。 + fn feed_reads(cols: usize, lines: usize, scrollback: usize, reads: &[&[u8]]) -> (u64, usize) { + let mut term = make_term(cols, lines, scrollback); + let mut parser: Processor = Processor::new(); + let mut pushed = 0u64; + for read in reads { + let mut counting = Counting::new(&mut term, pushed, scrollback); + parser.advance(&mut counting, read); + pushed = counting.finish(); + } + (pushed, term.grid().history_size()) + } + + /// `n` 行の台本。末尾に改行は付けないので、改行は `n - 1` 個になる。 + fn plain_lines(n: usize) -> Vec { + (1..=n) + .map(|i| format!("line {i}")) + .collect::>() + .join("\r\n") + .into_bytes() + } + + #[test] + fn 本画面の改行で通し番号が増える() { + // 24 行の画面に 30 行流す。改行は 29 個で、うち 23 個は画面の中を + // 下りるだけなので、履歴へ出るのは 6 行。 + let (pushed, history) = feed(20, 24, 100, &plain_lines(30)); + assert_eq!(pushed, 6); + assert_eq!(history, 6, "上限に届く前は端末の履歴行数と一致する"); + } + + #[test] + fn 代替画面にいるあいだは数えない() { + let mut script = b"\x1b[?1049h".to_vec(); + script.extend(plain_lines(30)); + script.extend(b"\x1b[?1049l"); + let (pushed, history) = feed(20, 24, 100, &script); + assert_eq!(pushed, 0, "代替画面に履歴は無い"); + assert_eq!(history, 0); + } + + #[test] + fn 上限に達しても増え続ける() { + // 4 行の画面に 40 行流す。履歴は 4 行で頭打ちになるが、 + // 押し出した行は 36 行ある。 + let (pushed, history) = feed(20, 4, 4, &plain_lines(40)); + assert_eq!(pushed, 36); + assert_eq!(history, 4, "履歴行数は上限で止まる"); + } + + #[test] + fn 折り返した行も数える() { + // 10 桁の画面を最下行まで埋めてから、35 文字を流す。 + // 折り返しも改行と同じように 1 行ずつ押し出す。 + // 行ごとの道からは見えないが、上限より下なら履歴行数の差に出る。 + let mut script = plain_lines(4); + script.extend(b"\r\n"); + script.extend([b'x'; 35]); + let (pushed, history) = feed(10, 4, 100, &script); + assert_eq!(pushed, 4, "改行 4 個のうち 1 個と、折り返し 3 回"); + assert_eq!(history, 4); + } + + #[test] + fn 画面消去で押し出された行も数える() { + // 4 行を埋めてから ED 2。書いてある 4 行が履歴へ出る。 + let mut script = plain_lines(4); + script.extend(b"\x1b[2J"); + let (pushed, history) = feed(20, 4, 100, &script); + assert_eq!(pushed, 4); + assert_eq!(history, 4); + } + + #[test] + fn スクロールバックを消した読みの数えを捨てない() { + // 4 行の画面に 10 行流して 6 行押し出し、同じ読みでスクロールバックを + // 消す。履歴が 0 に戻るので差は出ないが、押し出した 6 行は数えたままに + // する。`clear` は terminfo の E3 が有れば必ずこれを出すので、毎日通る道 + // である。捨てると、その読みより後の行の通し番号が 6 低く出る。 + let mut script = plain_lines(10); + script.extend(b"\x1b[3J"); + let (pushed, history) = feed(20, 4, 100, &script); + assert_eq!(pushed, 6, "消す前に押し出した 6 行を捨てない"); + assert_eq!(history, 0, "スクロールバックは消えている"); + } + + #[test] + fn 前の読みの履歴を消しても数えは残る() { + // 1 つめの読みで 6 行押し出し、2 つめの読みが 3 行押し出してから消す。 + // 履歴行数は 6 から 0 へ縮むので、差は負になる。総数は 9 のままである。 + // + // ED 3 なので印も立つ。縮みを見る腕だけを見ているのは下の試験である。 + let first = plain_lines(10); + let mut second = b"\r\n\r\n\r\n".to_vec(); + second.extend(b"\x1b[3J"); + let (pushed, history) = feed_reads(20, 4, 100, &[&first, &second]); + assert_eq!(pushed, 9, "両方の読みの押し出しが残る"); + assert_eq!(history, 0, "スクロールバックは消えている"); + } + + #[test] + fn 初期化で履歴が縮んでも数えは残る() { + // 1 つめの読みで 6 行押し出し、2 つめの読みが 3 行押し出してから端末を + // 初期化する(RIS)。`Grid::reset` が履歴を捨てるが、ED 3 と違って印は + // 付けていない。履歴行数が 6 から 0 へ縮むことだけが手がかりである。 + // + // この腕が無いと差の 0 がそのまま答えになり、総数が 6 へ落ちる。押し出した + // 行を落とすと、その後の行の通し番号がそのぶん低く出て、走査が返す時刻が + // ずっと古くなる(錠の外で公開していたときと同じ壊れ方である)。 + let first = plain_lines(10); + let mut second = b"\r\n\r\n\r\n".to_vec(); + second.extend(b"\x1bc"); + let (pushed, history) = feed_reads(20, 4, 100, &[&first, &second]); + assert_eq!(pushed, 9, "初期化の前に押し出した 3 行も残る"); + assert_eq!(history, 0, "履歴は消えている"); + } + + #[test] + fn 行削除で押し出された行も数える() { + // 画面の先頭で 2 行消すと、画面の中身が 2 行ぶん上へ送られる。 + let mut script = plain_lines(4); + script.extend(b"\x1b[H\x1b[2M"); + let (pushed, history) = feed(20, 4, 100, &script); + assert_eq!(pushed, 2); + assert_eq!(history, 2); + } + + #[test] + fn 上限に達してからも数え方は変わらない() { + // 同じ台本を、上限の広い端末と狭い端末へ流す。広いほうは履歴行数の差で + // 数え、狭いほうは行ごとの道で数える。両者は一致する。 + let mut script = plain_lines(10); + script.extend(b"\r\n\x1b[3S"); // 改行とスクロールアップ + script.extend(b"\x1bE\x1bE"); // NEL + script.extend(plain_lines(5)); + script.extend(b"\x1b[2J"); // 画面消去 + script.extend(plain_lines(5)); + + let (wide, history) = feed(20, 4, 10_000, &script); + assert_eq!(wide, history as u64, "上限に届かなければ履歴行数と一致する"); + let (narrow, capped) = feed(20, 4, 3, &script); + assert_eq!(capped, 3, "狭いほうは上限で止まっている"); + assert_eq!(narrow, wide, "上限に達しても同じ数になる"); + } + + #[test] + fn 範囲を絞った送りは数えない() { + // DECSTBM で画面の下半分だけを送り範囲にすると、その中の改行は履歴へ + // 入らない。行ごとの道からは最下行の改行に見えるので、上限より下では + // 履歴行数の差を答えにしないと数え過ぎる。 + let mut script = b"\x1b[3;6r\x1b[6;1H".to_vec(); + for i in 1..=10 { + script.extend(format!("line {i}\r\n").as_bytes()); + } + let (pushed, history) = feed(20, 6, 100, &script); + assert_eq!(pushed, 0, "範囲の中で送るだけなので履歴へは出ない"); + assert_eq!(history, 0); + } + + #[test] + fn 上限に達してからは折り返しを数えない() { + // 折り返しは `Term` の中で改行になるので、行ごとの道からは見えない。 + // 上限に達すると履歴行数の差も出ないので、そのぶんは数えられない。 + // 1 文字ごとに見れば数えられるが、読み手を遅くするので採らない。 + let mut script = plain_lines(4); + script.extend(b"\r\n\r\n\r\n\r\n"); // 最下行での改行 4 回 + script.extend([b'x'; 35]); // 折り返し 3 回 + let (pushed, history) = feed(10, 4, 2, &script); + assert_eq!(pushed, 4, "改行 4 回だけを数え、折り返し 3 回は数えない"); + assert_eq!(history, 2); + } + + #[test] + fn 読みをまたいでも数え続ける() { + // 読み手は読みごとに `Counting` を作り直し、前の読みまでの数を渡して続ける。 + // 上限より下なら、1 回で流しても分けて流しても同じ数になる。 + let mut script = plain_lines(10); + script.extend(b"\r\n"); + script.extend([b'z'; 47]); + script.extend(b"\r\n"); + script.extend(plain_lines(5)); + let (one, history) = feed(10, 4, 100, &script); + let (many, _) = feed_by_line(10, 4, 100, &script); + assert_eq!(one, history as u64, "1 回で流しても履歴行数と一致する"); + assert_eq!(many, one, "読みに分けても同じ数になる"); + } + + #[test] + fn 通し番号から行位置を出す() { + assert_eq!(serial(100, 0), 100); + assert_eq!(serial(100, -3), 97); + // 20 行流れたあと、通し番号 97 は 23 行上にある。 + assert_eq!(line_of(97, 120, 10000), Some(-23)); + // 履歴から押し出されていれば無い。 + assert_eq!(line_of(97, 120, 10), None); + } + + #[test] + fn 目盛りから時刻を引く() { + let t0 = Instant::now(); + let t1 = t0 + Duration::from_secs(1); + let t2 = t0 + Duration::from_secs(2); + let mut ticks = Ticks::new(); + ticks.push(10, t0); + ticks.push(20, t1); + ticks.push(30, t2); + assert_eq!(ticks.time_of(25), t1); + assert_eq!(ticks.time_of(30), t2); + assert_eq!( + ticks.time_of(5), + t0, + "一番古い目盛りより前なら、残っている最古の時刻を返す" + ); + } + + #[test] + fn 目盛りが無ければ作った時刻を返す() { + // 一度も行を押し出していない本でも、行から時刻が引けること。 + let before = Instant::now(); + let ticks = Ticks::new(); + let after = Instant::now(); + let at = ticks.time_of(0); + assert!(at >= before && at <= after, "本を開いた時刻である"); + assert_eq!(ticks.time_of(-5), at, "履歴側の行にも同じ時刻が付く"); + assert_eq!(ticks.newest(), at, "最後の目盛りも作った時刻になる"); + } + + #[test] + fn 最初に押し出した読みの目盛りが残る() { + // 20 分プロンプトで待ってから打った命令の出力は、その読みの時刻になること。 + // 作った時刻を目盛りとして積むと、この読みの目盛りが「同じ行数は積まない」で + // 落ちて、出力が 20 分前の時刻になってしまう。探している行はたいていそれである。 + let mut ticks = Ticks::new(); + let first = Instant::now() + Duration::from_secs(1200); + ticks.mark_read(0, 76, 0, first); + assert_eq!(ticks.time_of(30), first, "出力はその読みの時刻になる"); + } + + #[test] + fn 間の空いた出力は前の出力の時刻にならない() { + // 1 つめの出力で 76 行押し出し、10 分おいて 2 つめの出力で 100 行押し出した。 + // 目盛りの境より後の行は後の時刻、前の行は前の時刻になること。 + // + // ここは `push` を直に叩くので、打つ番号は素の押し出し総数である。 + // 読みから打つときにどの番号を選ぶか(カーソル行を足す)は `mark_read` の + // 仕事で、それは `画面に残っていた行は次の読みの時刻にならない` が見ている。 + let t0 = Instant::now(); + let t1 = t0 + Duration::from_secs(600); + let mut ticks = Ticks::new(); + ticks.push(0, t0); + ticks.push(76, t1); + assert_eq!(ticks.time_of(150), t1, "2 つめの出力で出た行"); + assert_eq!(ticks.time_of(30), t0, "1 つめの出力で出た行"); + } + + #[test] + fn 画面に残っていた行は次の読みの時刻にならない() { + // 1 つめの読みが 76 行押し出し、カーソルは画面の 24 行目に残った。 + // 2 つめの読みはそこから書き始めるので、その読みの行は 100 番から。 + // 76..100 は 1 つめの読みが画面に残した行で、1 つめの時刻のままである。 + // 読む前の総数 76 へ目盛りを打つと、この帯が 10 分新しい時刻を持ってしまう。 + let t0 = Instant::now(); + let t1 = t0 + Duration::from_secs(600); + let mut ticks = Ticks::new(); + ticks.mark_read(0, 76, 0, t0); + ticks.mark_read(76, 176, 24, t1); + assert_eq!(ticks.time_of(80), t0, "1 つめの読みが画面に残した行"); + assert_eq!(ticks.time_of(100), t1, "2 つめの読みが書き始めた行"); + assert_eq!(ticks.time_of(150), t1, "2 つめの読みで出た行"); + } + + #[test] + fn 押し出さない読みは目盛りを取らない() { + // 出力 → プロンプトの描き直し(押し出しなし)→ 10 分の間 → `ls` の出力。 + // 押し出さない読みが目盛りを取ると、同じ行数の目盛りは積まないので + // `ls` の行が前の出力の時刻になってしまう。 + let t0 = Instant::now(); + let prompt = t0 + Duration::from_secs(1); + let t1 = t0 + Duration::from_secs(600); + let mut ticks = Ticks::new(); + ticks.mark_read(0, 76, 0, t0); + ticks.mark_read(76, 76, 0, prompt); + ticks.mark_read(76, 176, 0, t1); + assert_eq!(ticks.time_of(150), t1, "`ls` の出力で出た行"); + assert_eq!(ticks.time_of(30), t0, "1 つめの出力で出た行"); + } + + #[test] + fn 同じ行数の目盛りは積まない() { + let t0 = Instant::now(); + let mut ticks = Ticks::new(); + ticks.push(10, t0); + ticks.push(10, t0 + Duration::from_secs(1)); + assert_eq!(ticks.time_of(10), t0, "先に見た時刻を残す"); + } + + #[test] + fn 目盛りは一秒おきに積む() { + let t0 = Instant::now(); + let mut ticks = Ticks::new(); + ticks.push(10, t0); + ticks.push(20, t0 + Duration::from_millis(500)); + assert_eq!(ticks.time_of(20), t0, "1 秒経っていないので積まない"); + ticks.push(30, t0 + Duration::from_secs(1)); + assert_eq!( + ticks.time_of(30), + t0 + Duration::from_secs(1), + "1 秒経てば積む" + ); + } + + #[test] + fn 目盛りは古いものから捨てる() { + let t0 = Instant::now(); + let mut ticks = Ticks::new(); + for i in 0..(TICKS as u64 + 100) { + ticks.push(i, t0 + Duration::from_secs(i)); + } + assert_eq!( + ticks.time_of(0), + t0 + Duration::from_secs(100), + "溢れた 100 件は捨て、残っている最古の時刻を返す" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 8cb70ee..f9ed54c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod history; mod input; mod keytest; mod latency; +mod lines; mod link; mod mouse; mod osc; @@ -16,6 +17,7 @@ mod probe; mod pty; mod rect; mod render; +mod scan; mod search; mod secret; mod session; @@ -32,7 +34,7 @@ use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::{viewport_to_point, TermMode}; use alacritty_terminal::vte::ansi::CursorShape; -use alacritty_terminal::vte::ansi::{ClearMode, Handler as _}; +use alacritty_terminal::vte::ansi::{ClearMode, Handler as _, Rgb}; use winit::application::ApplicationHandler; use winit::event::{ElementState, Ime, MouseButton, MouseScrollDelta, StartCause, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; @@ -156,7 +158,8 @@ fn main() { .then(Counters::default), resize_quiet_since: None, agent_state_at: None, - redactor_seed: redactor, + // 走査スレッドへも同じものを渡す。式を 2 度組み立てはしない。 + redactor_seed: Arc::new(redactor), }; if let Err(e) = event_loop.run_app(&mut app) { eprintln!("termit: {e}"); @@ -265,6 +268,155 @@ impl SearchState { } } +/// 打鍵が止まったと見なすまでの間。 +/// +/// 打鍵ごとには走らせない(仕様 §10)。走査は別スレッドへ出すが、 +/// 出したところで 1 字ごとに始めれば、14 本ぶんの錠を取り合うだけになる。 +const SCAN_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(150); + +/// 全セッションを横断する検索。左ペインの箱と、そこに出す当たりの一覧。 +/// +/// `⌘⇧F` では空のまま置く。語が変わるたびに `typed_at` を更新し、 +/// 手が止まったところで走らせる。受けた結果は次の打鍵まで固める(仕様 §12)。 +#[derive(Default)] +pub(crate) struct CrossSearch { + pub(crate) query: String, + /// いま待っている走査の世代。これと違う世代の結果は捨てる。 + pub(crate) generation: u64, + /// 最後に語が変わった時刻。走っていないあいだは `None`。 + pub(crate) typed_at: Option, + /// いま出している当たり。新しい順に並んでいる。 + pub(crate) hits: Vec, + pub(crate) selected: usize, + /// 一覧の先頭に出している位置。選んでいる件が外へ出ないよう追う。 + pub(crate) offset: usize, + /// 走査を投げて、結果がまだ帰っていない。 + /// + /// `typed_at` は投げた瞬間に消えるので、走っているあいだと、走り終えて + /// 0 件だったときを区別するには別に持つ必要がある。 + pub(crate) scanning: bool, +} + +/// 結果の一覧の下に出す一言。 +#[derive(Debug, PartialEq, Eq)] +enum ScanNote { + None, + Searching, + NoMatch, +} + +/// 結果の一覧の下に何を出すか。 +/// +/// 当たりが残っているあいだは出さない(前の語の結果を見ながら打ち継げる)。 +/// 走っているあいだ、または手がまだ止まっていないあいだは「無い」と言わない。 +fn scan_note(scanning: bool, typed: bool, hits: usize) -> ScanNote { + if hits > 0 { + ScanNote::None + } else if scanning || typed { + ScanNote::Searching + } else { + ScanNote::NoMatch + } +} + +/// 結果の一覧の代わりに出す字。置く件があれば `None`。 +/// +/// 件が 1 つも置けないなら必ず何かを出す。当たりがあるのに高さが足りないとき、 +/// 何も出さないと空の帯だけが残る。 +fn across_note_text(no_blocks: bool, note: &ScanNote) -> Option<&'static str> { + match note { + ScanNote::Searching => Some("searching…"), + ScanNote::NoMatch => Some("no match"), + ScanNote::None if no_blocks => Some("too narrow"), + ScanNote::None => None, + } +} + +impl CrossSearch { + /// 走査の結果を受ける。入れ替えたなら `true`。 + /// + /// 世代が合わないのは、この結果が帰るより先に次の走査を始めた場合である。 + /// 走査は別スレッドで走るので、結果は打った順に帰るとは限らない。 + /// 遅れて届いた古い結果で、新しい結果を上書きしない。 + fn accept(&mut self, generation: u64, hits: Vec) -> bool { + if generation != self.generation { + return false; + } + self.hits = hits; + self.selected = 0; + self.offset = 0; + self.scanning = false; + true + } + + /// 出ている当たりを捨てる。捨てるものがあったなら `true`。 + /// + /// 語を消し切ったときに呼ぶ。`hits` を書くのは `accept` とここだけなので、 + /// ここで捨てなければ、消した語の当たりがいつまでも一覧に残る。 + fn clear_hits(&mut self) -> bool { + let had = !self.hits.is_empty(); + self.scanning = false; + self.hits.clear(); + self.selected = 0; + self.offset = 0; + had + } + + /// 貼り付けた文字列を語へ足す。改行は空白にする(検索語に改行は要らない)。 + fn push_pasted(&mut self, text: &str) { + self.query.extend( + text.chars() + .map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }) + .filter(|c| !c.is_control()), + ); + } + + /// 選んでいる件が見える範囲に入るよう、出す位置を合わせる。 + /// + /// `rows` は結果を置ける件数。1 件が 2 段を取るので、左ペインの高さで変わる。 + /// 呼ぶ側は `across_window` で出すこと。割り付けと食い違うと件が隠れる。 + fn follow(&mut self, rows: usize) { + // 1 件も置けない高さでも、選んでいる件を追い越さない。 + let rows = rows.max(1); + if self.selected < self.offset { + self.offset = self.selected; + } else if self.selected >= self.offset + rows { + self.offset = self.selected + 1 - rows; + } + } +} + +/// 走査を始める頃合いか、まだ待つか。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScanWhen { + /// 語は変わっていない。起こしてもらう必要も無い。 + Idle, + /// もう始める。始めれば待ちは解けるので、この腕は起こす時刻を持たない。 + Start, + /// この時刻に起こしてもらう。 + WakeAt(std::time::Instant), +} + +/// 最後の打鍵からの間を見て、走らせるか・待つかを決める。 +/// +/// 決めるのは描画の最後だが、判定はここに出してある。窓の無いところでは +/// `State` を組み立てられないので、組み立てずに確かめられるようにする。 +fn scan_due( + typed_at: Option, + now: std::time::Instant, + debounce: std::time::Duration, +) -> ScanWhen { + let Some(at) = typed_at else { + return ScanWhen::Idle; + }; + let due = at + debounce; + if due <= now { + ScanWhen::Start + } else { + ScanWhen::WakeAt(due) + } +} + pub(crate) struct State { renderer: Renderer, manager: Manager, @@ -279,6 +431,13 @@ pub(crate) struct State { sidebar_width: f32, /// 画面の中の検索。 find: Option, + /// 全セッションを横断する検索。開いていなければ `None`。 + pub(crate) across: Option, + /// 次に走らせる走査の世代。1 つずつ増やすだけで、戻さない。 + /// + /// `CrossSearch` の中に置くと `⌘⇧F` で箱を開き直すたびに 0 へ戻る。 + /// 走り続けている前の走査が、新しい箱の世代と一致して受け取られてしまう。 + scan_generation: u64, /// 見えている範囲で強調するセル。 find_cells: std::collections::HashSet<(i32, usize)>, /// そのうち、いま選んでいる一致のセル。 @@ -305,7 +464,9 @@ pub(crate) struct State { /// 窓が完全に隠れているか。隠れているあいだは組み立てもしない。 occluded: bool, /// 画面で伏せる式。起動時に 1 度だけ組み立てる。 - pub(crate) redactor: crate::secret::Redactor, + /// + /// 横断検索の走査スレッドにも同じものを渡すので `Arc` で持つ。 + pub(crate) redactor: Arc, /// セッションの並びが変わった。少し置いてから書き出す。 state_dirty: bool, /// 最後に書き出した時刻。 @@ -435,7 +596,7 @@ struct App { /// 返事待ちを最後に調べた時刻。毎フレーム画面を読まないための間隔。 agent_state_at: Option, /// 画面を作るときに State へ渡す式。組み立ては起動時に済ませてある。 - redactor_seed: crate::secret::Redactor, + redactor_seed: Arc, } /// 画面の割り付け。すべてセル単位で扱う。 @@ -470,6 +631,9 @@ impl ApplicationHandler for App { if s.needs_redraw || s.manager.has_pending_size() || s.manager.sessions().iter().any(|x| x.shown_working) + // 横断検索の走査を始めるのは描画の最後なので、 + // 頃合いになっても描き直さなければ始まらない。 + || s.across.as_ref().is_some_and(|a| a.typed_at.is_some()) { s.request_redraw(); } @@ -510,6 +674,8 @@ impl ApplicationHandler for App { rename: None, sidebar_width: self.config.window.sidebar_width, find: None, + across: None, + scan_generation: 0, find_cells: Default::default(), find_current: Default::default(), mouse: MouseState::default(), @@ -640,6 +806,17 @@ impl ApplicationHandler for App { } state.recent_for = None; } + UiEvent::Scanned(generation, hits) => { + // 描き直すかどうかは `wants_redraw` では決められない。 + // 古い世代の結果なら、出ているものは何も変わらない。 + let took = state + .across + .as_mut() + .is_some_and(|across| across.accept(generation, hits)); + if took { + state.request_redraw(); + } + } } if redraw { state.request_redraw(); @@ -807,6 +984,11 @@ impl ApplicationHandler for App { if let Some(r) = &mut state.rename { r.input.push_str(&text); } + } else if state.across.is_some() { + if let Some(a) = &mut state.across { + a.query.push_str(&text); + } + self.queue_scan(); } else if state.find.is_some() { if let Some(f) = &mut state.find { f.query.push_str(&text); @@ -899,6 +1081,13 @@ impl App { } return; } + if state.across.is_some() { + self.on_across_key(&base, &event, mods); + if let Some(s) = &self.state { + s.request_redraw(); + } + return; + } if state.find.is_some() { self.on_find_key(&base, &event, mods); if let Some(s) = &self.state { @@ -1012,6 +1201,7 @@ impl App { state.search = None; state.find = Some(search::ScreenSearch::new()); } + Action::FindAcrossSessions => self.enter_across(), Action::SearchHistory => { state.picker = None; state.find = None; @@ -1396,11 +1586,37 @@ impl App { } } SidebarHit::Close(i) => state.manager.close(i), + SidebarHit::SearchBox => { + // 窓に入る(仕様 §9)。入っているなら、打った語を捨てない。 + if state.across.is_none() { + self.enter_across(); + } + // 並び順は変わらない。保存を予約しない。 + return; + } + SidebarHit::Hit(n) => { + // 選び直すだけ。飛ぶのは別の仕事である。 + // 掴んだことにはしない(仕様 §13)。 + let rows = across_window(state, &layout); + if let Some(across) = &mut state.across { + if n < across.hits.len() { + across.selected = n; + across.follow(rows); + } + } + return; + } } state.mark_state_dirty(); return; } + // 左ペインの外を押したら検索窓から出る。 + // 出ないと、端末を押したあとに打った字が窓へ入っていく。 + if state.across.is_some() && state.mouse.x >= layout.sidebar_px { + state.across = None; + } + // 端末領域。リンクの上なら開く。選択はしない。 if let Some(hit) = hyperlink_at(state, &layout) { link::open(&hit.uri); @@ -1615,6 +1831,9 @@ fn wants_redraw( UiEvent::Command(id, _) => shown(*id), // 目に見えるものは変わらない。 UiEvent::ClipboardStore(_, _) | UiEvent::ClipboardLoad(_, _) => false, + // 古い世代なら一覧は変わらない。受け取れたかどうかで分かれるので、 + // 描き直しは受け取る分岐が決める。 + UiEvent::Scanned(_, _) => false, } } @@ -1787,6 +2006,101 @@ impl App { } } + /// 全セッション検索の窓に入る(仕様 §9)。 + /// + /// 入る口は `⌘⇧F` と、窓を押すことの 2 つある。窓を押す側は `on_click` が + /// 先に重ねた一覧を閉じて帰るので、前提が違っても今は無事だが、閉じる順を + /// いつか動かせばそこが破れる。片方だけ直すことにならないよう、入る手順は + /// ここに 1 つだけ置く。 + fn enter_across(&mut self) { + let Some(state) = &mut self.state else { return }; + // 重ねた一覧は閉じる。`on_key` は picker・rename → across → find・search の + // 順に見るので、どちらに残しても「覆いは描かれているのに鍵が届かない」側が + // 出る。`rename` だけは残す(名前を打ち終える Esc がそちらへ行く)。 + state.picker = None; + state.search = None; + state.find = None; + // 掴んでいた行は放す。結果の一覧に並べ替えは効かせない(仕様 §13)。 + state.mouse.sidebar_drag = None; + state.across = Some(CrossSearch::default()); + // ペインを閉じていたら開く。窓が見えないまま鍵を奪わない。 + if !state.sidebar { + state.sidebar = true; + self.reflow(); + } + } + + /// 結果の `n` 件目を開く。そのセッションへ切り替え、行が残っていればそこへ飛ぶ。 + /// + /// 名前・題名・枝の件と、行が履歴から消えた件は、送り先が無いので切り替えて + /// 語を `⌘F` に渡すだけにする(仕様 §12)。 + fn open_hit(&mut self, n: usize) { + let Some(state) = &mut self.state else { return }; + let Some(across) = &state.across else { return }; + let Some(hit) = across.hits.get(n).cloned() else { + return; + }; + let query = across.query.clone(); + // 閉じたセッションの件なら、何も動かさず窓も閉じない。 + let Some(index) = state.manager.sessions().iter().position(|s| s.id == hit.id) else { + return; + }; + state.manager.select(index); + let mut find = search::ScreenSearch::new(); + find.query = query.clone(); + // 全セッション検索は無条件に大文字小文字を無視する(`scan::needle`)。 + // `RegexSearch::new` は smart-case なので、打った語をそのまま渡すと + // `SES` で当てた小文字の行を `⌘F` が見つけられず、強調が黙って点かない。 + // 旗を立てるのはこの受け渡しのときだけで、`⌘F` 自身は smart-case のままである。 + find.ignore_case = true; + find.rebuild(); + state.find = Some(find); + state.across = None; + + let serial = hit.serial.filter(|_| hit.kind == scan::HitKind::Line); + let needle = scan::needle(&query); + use alacritty_terminal::grid::Dimensions as _; + // 錠は 1 度の短い読みだけで放す。`pushed` は走査と同じ読み方で、錠の中で読む。 + // 読み手も錠の中で書くので、グリッドと総数は食い違わない。 + // それでも下で行を再確認するのは、走査してから選ぶまでに行が書き換わる + // (その場で描き直す UI、`resize` の折り返し直し)ことへの備えである。 + let landed = state.manager.selected().map(|session| { + let term = session.term.lock(); + let pushed = session.pushed.load(std::sync::atomic::Ordering::Relaxed); + let grid = term.grid(); + let land = landing( + serial, + pushed, + grid.history_size(), + grid.screen_lines() as i32, + ); + let row = match land { + Landing::Row(line) => { + // 走査と同じ述語(素の行でも伏せ字のあとでも当たる)で確かめる。 + let mut text = String::new(); + scan::row_text(grid, line, &mut text); + scan::matched(&state.redactor, &text, &needle).map(|_| line) + } + _ => None, + }; + (land, row) + }); + match landed { + Some((Landing::Row(_), Some(line))) => { + if let Some(session) = state.manager.selected() { + session.term.lock().scroll_to_point(Point::new( + alacritty_terminal::index::Line(line), + Column(0), + )); + } + } + // 数え違い・書き換え。 + Some((Landing::Search | Landing::Row(_), _)) => self.find_step(Direction::Left), + // 送り先が無い。語を渡すだけで画面は動かさない。 + Some((Landing::Nothing, _)) | None => {} + } + } + fn on_search_key(&mut self, key: &Key, event: &winit::event::KeyEvent, mods: ModifiersState) { let Some(state) = &mut self.state else { return }; let Some(search) = &mut state.search else { @@ -1881,6 +2195,150 @@ impl App { search.offset = 0; } + /// 検索窓の鍵(仕様 §9)。 + /// + /// `Esc` で窓を出る。`↑` `↓` で件を選ぶ。語を変えたら走らせ直す。 + /// 変換中の文字列は受けない。確定した分だけを `Ime::Commit` から受ける。 + fn on_across_key(&mut self, key: &Key, event: &winit::event::KeyEvent, mods: ModifiersState) { + let Some(state) = &mut self.state else { return }; + let layout = layout_of(&self.config, state); + // 窓に入る件数。割り付けと同じ関数で出す。 + let rows = across_window(state, &layout); + let Some(across) = &mut state.across else { + return; + }; + match key { + Key::Named(NamedKey::Escape) => { + // 窓を出て、元の一覧へ戻る。走っている走査の結果は、 + // 受け手が居なくなるので `Scanned` の腕で捨てられる。 + state.across = None; + return; + } + Key::Named(NamedKey::Enter) => { + let n = across.selected; + self.open_hit(n); + return; + } + Key::Named(NamedKey::ArrowDown) => { + if !across.hits.is_empty() { + across.selected = (across.selected + 1) % across.hits.len(); + across.follow(rows); + } + return; + } + Key::Named(NamedKey::ArrowUp) => { + if !across.hits.is_empty() { + let n = across.hits.len(); + across.selected = (across.selected + n - 1) % n; + across.follow(rows); + } + return; + } + Key::Named(NamedKey::Backspace) => { + across.query.pop(); + } + _ => { + // 貼り付けだけは `⌘` の和音でも通す。語を貼るのは普通の操作である。 + if input::action_for(key, event.physical_key, mods) == Some(Action::Paste) { + if let Some(text) = clipboard::paste() { + across.push_pasted(&text); + self.queue_scan(); + } + return; + } + let Some(text) = event.text.as_deref() else { + return; + }; + if text.chars().any(|c| c.is_control()) { + return; + } + // 変換中は、確定した文字列だけを `Ime::Commit` から受ける(仕様 §9)。 + // ここでも入れると同じ文字が二度入る。 + if !state.preedit.is_empty() { + return; + } + // `⌘` の和音は字として入れない。入れると `⌘⇧F` を + // 押し直すたびに "F" が語へ足される。 + if mods.super_key() { + return; + } + across.query.push_str(text); + } + } + self.queue_scan(); + } + + /// 語が変わったことを覚える。走らせるのは手が止まってからである(仕様 §10)。 + fn queue_scan(&mut self) { + let Some(across) = self.state.as_mut().and_then(|s| s.across.as_mut()) else { + return; + }; + across.typed_at = Some(std::time::Instant::now()); + } + + /// 走査を別スレッドで始める。 + /// + /// UI スレッドは `scan` を呼ばない(仕様 §4)。1 フレームの余白は 0.3ms ほどで、 + /// 14 本 × 10000 行の走査はそこに入らない。別スレッドへ渡すのは `Arc` の複製と + /// 短い文字列だけで、`Term` の中身は写さない(仕様 §10)。 + /// 結果は `UiEvent::Scanned` で帰ってくる。 + fn start_scan(&mut self) { + let ui_tx: crate::term::UiSender = self.proxy.clone().into(); + let Some(state) = &mut self.state else { return }; + let Some(across) = &mut state.across else { + return; + }; + // 走らせるかどうかに関わらず、待ちは解く。残すと過ぎた時刻で起こされ続ける。 + across.typed_at = None; + // 世代は、走らせない場合でも進める。進めずに抜けると、まだ走っている + // 前の走査の結果が `accept` に一致して、消した語の当たりが入ってしまう。 + state.scan_generation += 1; + across.generation = state.scan_generation; + // 語が空白だけなら走らせない。出ている当たりは、ここで捨てる。 + if across.query.trim().is_empty() { + let dropped = across.clear_hits(); + // ここへ来るのは描画の最後である。いま出ている一覧は描き終わっていて、 + // 捨てたことを知らせる機会が他に無い。捨てたときだけ描き直しを頼む。 + if dropped { + state.request_redraw(); + } + return; + } + let generation = across.generation; + let query = across.query.clone(); + across.scanning = true; + let targets: Vec = state + .manager + .sessions() + .iter() + .map(|s| scan::Target { + id: s.id, + term: s.term.clone(), + pushed: s.pushed.clone(), + ticks: s.ticks.clone(), + // 探すのは左ペインに出ている名前である(仕様 §7)。 + name: s.display_name().0, + title: s.window_title.clone(), + branch: s.branch.clone(), + }) + .collect(); + let redactor = state.redactor.clone(); + let spawned = std::thread::Builder::new() + .name("termit-scan".into()) + .spawn(move || { + let hits = scan::scan(&targets, &query, &redactor, scan::Limits::default()); + // 窓が閉じていれば送り先はもう無い。結果は捨てる。 + let _ = ui_tx.send_event(UiEvent::Scanned(generation, hits)); + }); + if let Err(e) = spawned { + // スレッドが作れないなら、この走査だけ諦める。端末は動き続ける。 + log::warn!("cannot start the cross-session scan: {e}"); + if let Some(across) = state.across.as_mut() { + across.scanning = false; + } + } + } + // ---------------------------------------------------------------- 描画 fn draw(&mut self, event_loop: &ActiveEventLoop) { @@ -2000,6 +2458,17 @@ impl App { want(std::time::Instant::now() + WORKING_REFRESH); } } + // 横断検索の語が変わっているなら、手が止まったころに走らせる。 + let typed_at = self + .state + .as_ref() + .and_then(|s| s.across.as_ref()) + .and_then(|across| across.typed_at); + match scan_due(typed_at, std::time::Instant::now(), SCAN_DEBOUNCE) { + ScanWhen::Idle => {} + ScanWhen::Start => self.start_scan(), + ScanWhen::WakeAt(due) => want(due), + } if let Some(at) = wake { event_loop.set_control_flow(ControlFlow::WaitUntil(at)); } @@ -2082,6 +2551,16 @@ mod sidebar { pub const RADIUS: f32 = 6.0; /// 上端に `+` を置く帯の高さ。 pub const HEADER: f32 = 34.0; + /// 帯の右端で `+` が取る幅。残りが検索窓になる(仕様 §6)。 + /// + /// 描く側と当たり判定で同じ値を使う。食い違うと、窓を押したのに + /// セッションが増える。 + pub const PLUS_ZONE: f32 = 24.0; + /// 変換中に、検索窓の中で変換中の文字列へ必ず残す幅。 + /// + /// 確定した語が窓いっぱいに伸びていると、変換中の文字列に幅が残らず、 + /// いま打っている字が見えなくなる。全角で 4 字ぶん(12pt)より少し広く取る。 + pub const MIN_PREEDIT: f32 = 56.0; /// 区切り線の上下の余白。 pub const SEP_PAD: f32 = 8.0; @@ -2093,6 +2572,13 @@ mod sidebar { pub const SIZE_BRANCH: f32 = 10.0; /// 直近のコマンド。 pub const SIZE_RECENT: f32 = 11.0; + /// 検索窓に打った語。 + pub const SIZE_QUERY: f32 = 12.0; + + /// 当たった語に敷く色を、文字の両端へ広げる量。 + /// + /// 字にぴったり敷くと、前後の字と地続きに見えて語の切れ目が読めない。 + pub const HIT_PAD: f32 = 4.0; } /// `⌘C` で写す文字を決める。 @@ -2145,6 +2631,20 @@ fn on_divider(state: &State, layout: &Layout, px: f32) -> bool { && (px - layout.sidebar_px).abs() <= DIVIDER_GRAB * state.renderer.scale() } +/// 実行状態の印の色。セッションの一覧と結果の一覧で同じ組みにする。 +/// +/// 待っている>動いている>止まっている、の順に強い。 +/// 待ちは黄色。動いている緑とも、異常終了の赤とも違う色にする。 +fn mark_color(theme: &Theme, exit: Option, working: bool, blocked: bool) -> Rgb { + match exit { + None if blocked => theme.ansi[3], + None if working => theme.accent, + None => theme.fg_tertiary, + Some(0) => theme.fg_tertiary, + Some(_) => theme.warn, + } +} + /// 左ペインの文字の体裁。桁に縛られないので、等幅でない書体で組む。 /// /// 同じ幅に多く入り、Warp の一覧の見え方に近くなる。 @@ -2157,8 +2657,10 @@ fn style(size: f32, bold: bool) -> TextStyle { } } -/// 左ペインの 1 セッションぶんの領域。描画と当たり判定で同じ値を使う。 +/// 左ペインの 1 件ぶんの領域。描画と当たり判定で同じ値を使う。 struct Block { + /// セッションの一覧では `sessions()` の添字。結果の一覧では + /// `across.hits` の添字で、送った位置(`offset`)を足した絶対の値。 index: usize, depth: usize, /// 矩形の上端(画素)。 @@ -2178,39 +2680,142 @@ struct SidebarLayout { sep_y: f32, } +/// 一覧に使える高さ(画素)。下段に直近のコマンドを出す余地を残す。 +fn sidebar_limit(state: &State, layout: &Layout) -> f32 { + let height_px = layout.rows as f32 * state.renderer.cell().height; + height_px - 120.0 * state.renderer.scale() +} + +/// セッションの一覧を、結果の一覧に差し替えるか。 +/// +/// 語が空、または空白だけなら元の一覧へ戻る(仕様 §6)。 +/// 走査を始めない判定(`start_scan`)と同じ規則にしてある。違えてしまうと、 +/// 語を消し切って当たりを捨てたあとも、空の一覧が出たままになる。 +fn showing_hits(across: Option<&CrossSearch>) -> bool { + across.is_some_and(|a| !a.query.trim().is_empty()) +} + +/// 結果 1 件ぶんの高さ(画素)。1 件は 2 段を取る(仕様 §6)。 +/// +/// 測った段の高さを受け取るので、`Renderer` を立てずに試験できる。 +fn across_block_height(sc: f32, title_h: f32, sub_h: f32) -> f32 { + (sidebar::PAD_TOP + sidebar::PAD_BOTTOM) * sc + title_h + sub_h +} + +/// 当たりが 1 件も無いときに、代わりの字を出す高さ(画素)。 +/// +/// 1 件目と同じ位置から出す。割り付けもこの字のぶんを空ける。 +fn across_note_y(sc: f32) -> f32 { + sidebar::HEADER * sc + sidebar::PAD_TOP * sc +} + +/// `top` から `limit` までに置ける件数。 +fn across_rows(top: f32, limit: f32, height: f32, gap: f32) -> usize { + // 高さの無い件を数え続けない。ここは描画の経路にある。 + if height <= 0.0 { + return 0; + } + let mut rows = 0; + let mut y = top; + while y + height <= limit { + rows += 1; + y += height + gap; + } + rows +} + +/// いま結果を置ける件数。 +/// +/// 割り付け(`across_blocks`)と窓送り(`CrossSearch::follow`)で同じ値を使う。 +/// 食い違うと、選んだ件が見えない位置へ出る。 +fn across_window(state: &State, layout: &Layout) -> usize { + let sc = state.renderer.scale(); + let height = across_block_height( + sc, + state.renderer.line_height_px(sidebar::SIZE_TITLE), + state.renderer.line_height_px(sidebar::SIZE_SUB), + ); + across_rows( + sidebar::HEADER * sc, + sidebar_limit(state, layout), + height, + sidebar::GAP * sc, + ) +} + +/// 結果の一覧の領域を割り付ける。入りきらない件は `offset` から出す。 +fn across_blocks(state: &State, layout: &Layout) -> Vec { + let Some(across) = &state.across else { + return Vec::new(); + }; + let sc = state.renderer.scale(); + let height = across_block_height( + sc, + state.renderer.line_height_px(sidebar::SIZE_TITLE), + state.renderer.line_height_px(sidebar::SIZE_SUB), + ); + let last = (across.offset + across_window(state, layout)).min(across.hits.len()); + let mut blocks = Vec::new(); + let mut y = sidebar::HEADER * sc; + for index in across.offset..last { + // 結果の一覧に系統樹の字下げは無い(仕様 §13)。 + blocks.push(Block { + index, + depth: 0, + top: y, + height, + }); + y += height + sidebar::GAP * sc; + } + blocks +} + fn sidebar_layout(state: &State, layout: &Layout) -> SidebarLayout { let sc = state.renderer.scale(); let width = layout.sidebar_px; let rect_x = sidebar::MARGIN_L * sc; let rect_w = (width - (sidebar::MARGIN_L + sidebar::MARGIN_R) * sc).max(0.0); - let height_px = layout.rows as f32 * state.renderer.cell().height; - // 下段に直近のコマンドを出す余地を残す。 - let limit = height_px - 120.0 * sc; + let limit = sidebar_limit(state, layout); let mut blocks = Vec::new(); let mut y = sidebar::HEADER * sc; - for tree in state.manager.tree_rows() { - let Some(s) = state.manager.sessions().get(tree.index) else { - continue; - }; - let mut content = state.renderer.line_height_px(sidebar::SIZE_TITLE); - if s.window_title.is_some() { - content += state.renderer.line_height_px(sidebar::SIZE_SUB); - } - if s.branch.is_some() { - content += state.renderer.line_height_px(sidebar::SIZE_BRANCH); + if showing_hits(state.across.as_ref()) { + // 打っているあいだ、一覧は結果の一覧に差し替わる(仕様 §6)。 + blocks = across_blocks(state, layout); + if let Some(b) = blocks.last() { + y = b.top + b.height + sidebar::GAP * sc; + } else { + // 1 件も置かないときは、代わりに出す字のぶんを空ける。 + // 空けないと、区切り線と直近のコマンドがその字に重なる。 + y = across_note_y(sc) + + state.renderer.line_height_px(sidebar::SIZE_SUB) + + sidebar::PAD_BOTTOM * sc + + sidebar::GAP * sc; } - let height = (sidebar::PAD_TOP + sidebar::PAD_BOTTOM) * sc + content; - if y + height > limit { - break; + } else { + for tree in state.manager.tree_rows() { + let Some(s) = state.manager.sessions().get(tree.index) else { + continue; + }; + let mut content = state.renderer.line_height_px(sidebar::SIZE_TITLE); + if s.window_title.is_some() { + content += state.renderer.line_height_px(sidebar::SIZE_SUB); + } + if s.branch.is_some() { + content += state.renderer.line_height_px(sidebar::SIZE_BRANCH); + } + let height = (sidebar::PAD_TOP + sidebar::PAD_BOTTOM) * sc + content; + if y + height > limit { + break; + } + blocks.push(Block { + index: tree.index, + depth: tree.depth, + top: y, + height, + }); + y += height + sidebar::GAP * sc; } - blocks.push(Block { - index: tree.index, - depth: tree.depth, - top: y, - height, - }); - y += height + sidebar::GAP * sc; } let sep_y = y - sidebar::GAP * sc + sidebar::SEP_PAD * sc; SidebarLayout { @@ -2247,8 +2852,12 @@ fn sidebar_boundary_y(sl: &SidebarLayout, at: usize, sc: f32) -> f32 { /// 左ペインのどこを押したか。 enum SidebarHit { NewSession, + /// 見出しの帯の検索窓。押すと窓に入る(仕様 §9)。 + SearchBox, Select(usize), Close(usize), + /// 結果の一覧の件。添字は `across.hits` の絶対の位置。 + Hit(usize), } /// 画素の位置で当たりを見る。 @@ -2259,14 +2868,22 @@ fn sidebar_hit(state: &State, layout: &Layout, px: f32, py: f32) -> Option plus_right - 24.0 * sc).then_some(SidebarHit::NewSession); + if px > plus_right - sidebar::PLUS_ZONE * sc { + return Some(SidebarHit::NewSession); + } + return Some(SidebarHit::SearchBox); } + // 結果の一覧に × も並べ替えも効かせない(仕様 §13)。 + let hits = showing_hits(state.across.as_ref()); for b in &sl.blocks { if py < b.top || py >= b.top + b.height { continue; } + if hits { + return Some(SidebarHit::Hit(b.index)); + } // × は、カーソルが乗っているときだけ 1 段目の右端に出る。 // 出ていない位置で閉じないよう、同じ範囲でだけ受ける。 let first_line = state.renderer.line_height_px(sidebar::SIZE_TITLE); @@ -2280,6 +2897,322 @@ fn sidebar_hit(state: &State, layout: &Layout, px: f32, py: f32) -> Option (f32, f32) { + let avail = avail.max(0.0); + if !composing { + return (avail, 0.0); + } + let preedit = min_preedit.min(avail); + (avail - preedit, preedit) +} + +/// 語を描いたあとに、変換中の文字列へ残る幅。 +/// +/// 取り分は語の予算にだけ掛ける(`split_box_width`)。変換中の文字列は、 +/// 語が実際に使わなかった幅をすべて使える。 +fn preedit_room(avail: f32, query_used: f32) -> f32 { + (avail - query_used).max(0.0) +} + +/// 結果の 1 件を描くのに要るもの。`renderer` を触る前に写し取る。 +struct HitRow { + top: f32, + height: f32, + selected: bool, + /// 1 段目に出す名前。引けなかったセッションでは `CLOSED_SESSION`。 + name: String, + is_path: bool, + /// `⌘` の札。引けなかったセッションでは出さない。 + label: Option, + exit: Option, + /// 走査のあいだに終わって消えたセッション。終了コードは分からない。 + closed: bool, + working: bool, + blocked: bool, + kind: scan::HitKind, + text: String, + /// `text` の中の当たりのバイト位置。 + hit: std::ops::Range, +} + +/// 結果の一覧の 1 件ずつを、描く前に写し取る。 +/// +/// `Hit.id` は走査中に終わったセッションを指しうる。位置ではなく番号で引き、 +/// 引けなければ 1 段目だけを代わりの字にする(当たった文は残す)。 +fn collect_hit_rows(state: &State, sl: &SidebarLayout) -> Vec { + let Some(across) = &state.across else { + return Vec::new(); + }; + let tree = state.manager.tree_rows(); + sl.blocks + .iter() + .filter_map(|b| { + let hit = across.hits.get(b.index)?; + let found = state + .manager + .sessions() + .iter() + .enumerate() + .find(|(_, s)| s.id == hit.id); + let (name, is_path, label, exit, closed, working, blocked) = match found { + Some((i, s)) => { + let (name, is_path) = s.display_name(); + let exit = match s.state { + RunState::Running => None, + RunState::Exited(code) => Some(code), + }; + // 札は一覧に出ているのと同じ番号にする。 + let label = tree + .iter() + .position(|r| r.index == i) + .and_then(input::index_label); + (name, is_path, label, exit, false, s.is_working(), s.blocked) + } + // 終わって消えたセッション。止まっている印で出す。 + None => ( + CLOSED_SESSION.to_string(), + false, + None, + None, + true, + false, + false, + ), + }; + Some(HitRow { + top: b.top, + height: b.height, + selected: b.index == across.selected, + name, + is_path, + label, + exit, + closed, + working, + blocked, + kind: hit.kind, + text: hit.text.clone(), + hit: hit.hit.clone(), + }) + }) + .collect() +} + +/// 結果の一覧を描く。 +/// +/// 1 段目は既存の行と同じ組み(状態の点、名前、`⌘` の札)。 +/// 2 段目は当たった文で、当たりを中心に詰め、当たった語に色を敷く(仕様 §6)。 +fn draw_across_hits(state: &mut State, sl: &SidebarLayout, theme: &Theme) { + let sc = state.renderer.scale(); + let title_h = state.renderer.line_height_px(sidebar::SIZE_TITLE); + let sub_h = state.renderer.line_height_px(sidebar::SIZE_SUB); + let small_h = state.renderer.line_height_px(sidebar::SIZE_BRANCH); + let text_x = sl.rect_x + sidebar::TEXT_INSET * sc; + let right = sl.rect_x + sl.rect_w; + let title_st = style(sidebar::SIZE_TITLE, false); + let sub_st = style(sidebar::SIZE_SUB, false); + let small_st = style(sidebar::SIZE_BRANCH, false); + + // 当たりが 1 件も無いときは、何も無い理由を出す。 + // 当たりは語より 1 走査ぶん遅れることがあるので(`queue_scan` は時刻を + // 押すだけ)、走らせる前に「無い」と言わない。 + // 1 件も置けない高さのときも、割り付けは一言のぶんを空けている。 + // 空の帯を残さず、理由を出す。 + let note = state.across.as_ref().and_then(|a| { + let n = scan_note(a.scanning, a.typed_at.is_some(), a.hits.len()); + across_note_text(sl.blocks.is_empty(), &n) + }); + if let Some(note) = note { + state + .renderer + .put_text_px(text_x, across_note_y(sc), note, sub_st, theme.fg_tertiary); + return; + } + + for row in collect_hit_rows(state, sl) { + if row.selected { + state.renderer.fill_px( + sl.rect_x, + row.top, + sl.rect_w, + row.height, + theme.surface, + sidebar::RADIUS * sc, + ); + } + let mut y = row.top + sidebar::PAD_TOP * sc; + + // ---- 1 段目:状態の点、名前、`⌘` の札 + let label_w = match &row.label { + Some(l) => state.renderer.measure_px(l, small_st), + None => 0.0, + }; + let avail = (right - 12.0 * sc - label_w - text_x).max(0.0); + let name = if row.is_path { + state.renderer.fit_head(&row.name, title_st, avail) + } else { + state.renderer.fit_tail(&row.name, title_st, avail) + }; + let ended = row.closed || row.exit.is_some(); + let fg = if !ended { + theme.fg_primary + } else { + theme.fg_tertiary + }; + state.renderer.put_text_px(text_x, y, &name, title_st, fg); + state.renderer.put_text_px( + sl.rect_x + sidebar::MARK_INSET * sc, + y + (title_h - small_h) / 2.0, + if ended { "○" } else { "●" }, + small_st, + mark_color(theme, row.exit, row.working, row.blocked), + ); + if let Some(label) = &row.label { + state.renderer.put_text_px( + right - 6.0 * sc - label_w, + y + (title_h - small_h) / 2.0, + label, + small_st, + theme.fg_tertiary, + ); + } + y += title_h; + + // ---- 2 段目:当たった文 + // 題名と枝に当たったときは、一覧と同じ印を同じ色で置く。 + // 置かないと、出力の行に当たったのと見分けが付かない。 + let (mark, mark_fg) = match row.kind { + scan::HitKind::Title => ("✻ ", theme.accent), + scan::HitKind::Branch => ("⋔ ", theme.fg_tertiary), + scan::HitKind::Line | scan::HitKind::Name => ("", theme.fg_tertiary), + }; + let mut x = text_x; + x += state.renderer.put_text_px(x, y, mark, sub_st, mark_fg); + let limit = (right - 8.0 * sc - x).max(0.0); + // 当たりを中心にして詰める。末尾も先頭も落とす詰め方では、 + // 探した語そのものが落ちうる(仕様 §6)。 + let (shown, at) = state.renderer.fit_center(&row.text, sub_st, limit, row.hit); + // `at` は `shown` のバイト位置。境を外した範囲で落ちないようにする。 + let head = shown.get(..at.start).unwrap_or(""); + let word = shown.get(at.start..at.end).unwrap_or(""); + let tail = shown.get(at.end..).unwrap_or(""); + x += state + .renderer + .put_text_px(x, y, head, sub_st, theme.fg_secondary); + if !word.is_empty() { + let word_w = state.renderer.measure_px(word, sub_st); + let pad = sidebar::HIT_PAD * sc / 2.0; + state.renderer.fill_px( + x - pad, + y, + word_w + pad * 2.0, + sub_h, + theme.search_hit, + 2.0 * sc, + ); + } + x += state + .renderer + .put_text_px(x, y, word, sub_st, theme.search_fg); + state + .renderer + .put_text_px(x, y, tail, sub_st, theme.fg_secondary); + } +} + pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { let sc = state.renderer.scale(); let sl = sidebar_layout(state, layout); @@ -2288,6 +3221,9 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { .renderer .fill_px(0.0, 0.0, sl.width, height_px, theme.chrome_bg, 0.0); + // 帯の左は検索窓。`+` より先に描く(仕様 §6)。 + draw_search_box(state, &sl, theme); + // 押せる目印。キーが効かない環境でもここから増やせる。 let plus_w = state .renderer @@ -2302,6 +3238,20 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { theme.accent, ); + if showing_hits(state.across.as_ref()) { + draw_across_hits(state, &sl, theme); + // 一覧を出していないあいだも「描いた状態」は合わせておく。 + // 残しておくと、背景のセッションが字を出すたびに + // `wants_redraw` が描き直しを頼み続ける。 + for s in state.manager.sessions_mut() { + let working = s.is_working(); + s.shown_working = working; + s.shown_blocked = s.blocked; + } + draw_sidebar_recent(state, &sl, theme, height_px); + return; + } + let selected = state.manager.selected_index(); // 描いた「動いている」状態は覚えておく。変わるときだけ描き直す。 let mut shown: Vec<(usize, bool, bool)> = Vec::with_capacity(sl.blocks.len()); @@ -2388,22 +3338,13 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { // 実行状態は左端に置く。Warp がアバターを置いている位置にあたる。 // 動いているあいだは明るく、待っているあいだは沈める。 let mark = if exit.is_none() { "●" } else { "○" }; - // 待っている>動いている>止まっている、の順に強い。 - // 待ちは黄色。動いている緑とも、異常終了の赤とも違う色にする。 - let mark_color = match exit { - None if blocked => theme.ansi[3], - None if working => theme.accent, - None => theme.fg_tertiary, - Some(0) => theme.fg_tertiary, - Some(_) => theme.warn, - }; let small_h = state.renderer.line_height_px(sidebar::SIZE_BRANCH); state.renderer.put_text_px( sl.rect_x + sidebar::MARK_INSET * sc + indent, y + (title_h - small_h) / 2.0, mark, style(sidebar::SIZE_BRANCH, false), - mark_color, + mark_color(theme, exit, working, blocked), ); // 右端は、ふだんは ⌘ の番号、カーソルが乗っているときは閉じる印。 // 両方を常に置くと名前の幅が足りない。 @@ -2498,7 +3439,12 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { } } - // 直近のコマンド。何もなければ区切りごと出さない。 + draw_sidebar_recent(state, &sl, theme, height_px); +} + +/// 下段の直近のコマンド。何もなければ区切りごと出さない。 +fn draw_sidebar_recent(state: &mut State, sl: &SidebarLayout, theme: &Theme, height_px: f32) { + let sc = state.renderer.scale(); if state.recent.is_empty() { return; } @@ -2676,10 +3622,16 @@ pub(crate) fn draw_terminal(state: &mut State, layout: &Layout, theme: &Theme) { if let Some(y) = cursor_row { let x = layout.term_col + cursor_col.min(layout.term_cols - 1); let cell = state.renderer.cell(); - state.cursor_px = Some((x as f32 * cell.width, y as f32 * cell.height)); + // 検索窓に入っているあいだ、変換中の文字列と候補窓は窓の側が持つ。 + // `draw_sidebar` は `draw_terminal` より先に走るので、ここで + // 書き直すと候補窓が端末のカーソルへ戻ってしまう。 + let owns_ime = state.across.is_none(); + if owns_ime { + state.cursor_px = Some((x as f32 * cell.width, y as f32 * cell.height)); + } // 変換中の文字列は、確定するまで子プロセスへ渡さない。 // 見えないと何を打っているか分からないので、カーソルの位置に出す。 - let preedit = (state.find.is_none() && state.search.is_none()) + let preedit = (owns_ime && state.find.is_none() && state.search.is_none()) .then(|| state.preedit.clone()) .filter(|p| !p.is_empty()); if let Some(text) = preedit { @@ -2758,7 +3710,7 @@ pub(crate) fn draw_bottom(state: &mut State, layout: &Layout, theme: &Theme) { x += state .renderer .put_text_px(x, ty, &input, st, theme.fg_primary); - x += draw_preedit_px(state, x, ty, st, theme); + x += draw_preedit_px(state, x, ty, st, theme, None); state .renderer .fill_px(x, ty, 2.0 * sc, line_h, theme.cursor, 0.0); @@ -2798,7 +3750,7 @@ pub(crate) fn draw_bottom(state: &mut State, layout: &Layout, theme: &Theme) { theme.fg_primary }; x += state.renderer.put_text_px(x, ty, &query, st, fg); - x += draw_preedit_px(state, x, ty, st, theme); + x += draw_preedit_px(state, x, ty, st, theme, None); state .renderer .fill_px(x, ty, 2.0 * sc, line_h, theme.cursor, 0.0); @@ -2901,7 +3853,7 @@ pub(crate) fn draw_bottom(state: &mut State, layout: &Layout, theme: &Theme) { x += state .renderer .put_text_px(x, ty, &query, st, theme.fg_primary); - x += draw_preedit_px(state, x, ty, st, theme); + x += draw_preedit_px(state, x, ty, st, theme, None); state .renderer .fill_px(x, ty, 2.0 * sc, line_h, theme.cursor, 0.0); @@ -2942,14 +3894,27 @@ pub(crate) fn draw_bottom(state: &mut State, layout: &Layout, theme: &Theme) { } /// 変換中の文字列を帯の中に出す。返す値は描いた幅。 -fn draw_preedit_px(state: &mut State, x: f32, y: f32, st: TextStyle, theme: &Theme) -> f32 { +/// +/// `bound` は窓の中で使うときの、残りの幅と下敷きの色。幅を越える分は切る。 +fn draw_preedit_px( + state: &mut State, + x: f32, + y: f32, + st: TextStyle, + theme: &Theme, + bound: Option<(f32, Rgb)>, +) -> f32 { if state.preedit.is_empty() { return 0.0; } - let text = state.preedit.clone(); + let (text, under) = match bound { + // 窓の中では、いま打っている末尾を残す。他の呼び手は切らない。 + Some((room, color)) => (state.renderer.fit_head(&state.preedit, st, room), color), + None => (state.preedit.clone(), theme.chrome_bg), + }; let w = state.renderer.measure_px(&text, st); let h = state.renderer.line_height_px(st.size); - state.renderer.fill_px(x, y, w, h, theme.chrome_bg, 0.0); + state.renderer.fill_px(x, y, w, h, under, 0.0); state .renderer .put_text_px(x, y, &text, st, theme.fg_primary); @@ -3302,6 +4267,12 @@ mod tests { Some(3), false )); + // 走査の結果は、受け取れたかどうかで変わる。ここでは決めない。 + assert!(!wants_redraw( + &UiEvent::Scanned(1, Vec::new()), + Some(3), + false + )); } fn px(y: f64) -> MouseScrollDelta { @@ -3422,12 +4393,149 @@ mod tests { let got = bracketed("x", TermMode::BRACKETED_PASTE); assert_eq!(got, b"\x1b[200~x\x1b[201~"); } + + /// 走査は別スレッドで走るので、結果は打った順に帰ってくるとは限らない。 + /// 遅れて来た古い結果で、新しい結果を上書きしないこと。 + mod 横断検索の受け口 { + use super::*; + + fn 当たり(text: &str) -> crate::scan::Hit { + crate::scan::Hit { + id: 1, + kind: crate::scan::HitKind::Line, + serial: Some(0), + at: None, + text: text.into(), + hit: 0..text.len(), + } + } + + #[test] + fn 古い世代の結果は捨てる() { + // 世代 2 を待っているあいだに、1 本前の走査が結果を持って帰ってくる。 + let mut across = CrossSearch { + query: "ses".into(), + generation: 2, + ..CrossSearch::default() + }; + assert!(!across.accept(1, vec![当たり("古い")]), "古い世代を受けた"); + assert!(across.hits.is_empty(), "古い世代の結果で入れ替わった"); + + // 待っていた世代なら受ける。選んでいた位置は先頭へ戻す。 + across.selected = 4; + across.offset = 2; + assert!(across.accept(2, vec![当たり("新しい")])); + assert_eq!(across.hits.len(), 1); + assert_eq!(across.hits[0].text, "新しい"); + assert_eq!((across.selected, across.offset), (0, 0)); + + // 受け取ったあとで届いた古い世代も捨てる。 + assert!(!across.accept(1, vec![当たり("もっと古い")])); + assert_eq!(across.hits[0].text, "新しい", "古い結果に戻された"); + } + + /// 語を消し切ったら走査は始まらない。出ている当たりを捨てるのは + /// このときだけで、捨てなければ、消した語の当たりが一覧に残り続ける。 + #[test] + fn 語を消したら当たりも捨てる() { + let mut across = CrossSearch { + hits: vec![当たり("古い")], + selected: 3, + offset: 2, + ..CrossSearch::default() + }; + assert!(across.clear_hits(), "捨てたことを知らせない"); + assert!(across.hits.is_empty()); + assert_eq!((across.selected, across.offset), (0, 0)); + // 空のまま呼ばれても、描き直しは頼まない。 + assert!(!across.clear_hits()); + } + + /// 打鍵のたびには走らせない(仕様 §10)。 + #[test] + fn 打鍵が止まるまで走らせない() { + let now = std::time::Instant::now(); + let 間 = SCAN_DEBOUNCE; + // 語が変わっていなければ、起こしてもらう必要も無い。 + assert_eq!(scan_due(None, now, 間), ScanWhen::Idle); + // まだ間が空いていなければ、その時刻に起こしてもらう。 + let typed = now - 間 / 2; + assert_eq!(scan_due(Some(typed), now, 間), ScanWhen::WakeAt(typed + 間)); + // ちょうど過ぎたところから始める。 + assert_eq!(scan_due(Some(now - 間), now, 間), ScanWhen::Start); + assert_eq!(scan_due(Some(now - 間 * 2), now, 間), ScanWhen::Start); + } + + #[test] + fn 選んでいる件が窓から出ないよう追う() { + // 窓は 3 件。下へ出たら、下端に付いてくる。 + let mut across = CrossSearch { + selected: 5, + ..CrossSearch::default() + }; + across.follow(3); + assert_eq!(across.offset, 3); + // 窓の中にいるあいだは動かさない。 + across.selected = 4; + across.follow(3); + assert_eq!(across.offset, 3); + // 上へ出たら、上端に付いてくる。 + across.selected = 1; + across.follow(3); + assert_eq!(across.offset, 1); + // 1 件も置けない高さでも、選んでいる件を追い越さない。 + across.selected = 7; + across.follow(0); + assert_eq!(across.offset, 7); + } + } +} + +/// `Enter` を押したとき、その件をどう扱うか。 +#[derive(Debug, PartialEq, Eq)] +enum Landing { + /// 送り先が無い。セッションを切り替えて語を渡すだけ(仕様 §12)。 + /// 名前・題名・枝の件と、履歴から消えた行。 + Nothing, + /// 数え違い。`⌘F` に探させる。 + Search, + /// この行を見に行く。まだ語が有れば送り、無ければ `Search` と同じにする。 + Row(i32), +} + +/// 通し番号を、いまの `pushed` で行位置へ引き直して扱いを決める。 +fn landing(serial: Option, pushed: u64, history: usize, screen_lines: i32) -> Landing { + let Some(serial) = serial else { + return Landing::Nothing; + }; + match lines::line_of(serial, pushed, history) { + None => Landing::Nothing, + Some(line) if line > screen_lines - 1 => Landing::Search, + Some(line) => Landing::Row(line), + } } #[cfg(test)] mod search_view_tests { use super::*; + #[test] + fn 消えた行は送らずに語だけ渡す() { + assert_eq!(landing(None, 100, 10000, 24), Landing::Nothing); + assert_eq!(landing(Some(97), 120, 10, 24), Landing::Nothing); + } + + #[test] + fn 流れた行は通し番号から引き直す() { + assert_eq!(landing(Some(97), 120, 10000, 24), Landing::Row(-23)); + assert_eq!(landing(Some(120), 120, 10000, 24), Landing::Row(0)); + } + + #[test] + fn 画面より下に出た件は数え違いとして扱う() { + assert_eq!(landing(Some(200), 120, 10000, 24), Landing::Search); + } + fn view(selected: usize, offset: usize) -> SearchState { SearchState { query: String::new(), @@ -3477,6 +4585,104 @@ mod search_view_tests { } } } + + fn cross(query: &str) -> CrossSearch { + CrossSearch { + query: query.into(), + ..CrossSearch::default() + } + } + + #[test] + fn 語が空白だけなら元の一覧に戻る() { + // 結果 0 件の一覧を出すのではなく、セッションの一覧を組む(仕様 §6)。 + // 走査を始めない判定(`start_scan`)と同じ規則でなければ、 + // 当たりが捨てられたあとも空の一覧が出たままになる。 + assert!(!showing_hits(None)); + assert!(!showing_hits(Some(&cross("")))); + assert!(!showing_hits(Some(&cross(" ")))); + assert!(!showing_hits(Some(&cross("\t ")))); + // 全角の空白も空白として扱う。 + assert!(!showing_hits(Some(&cross(" ")))); + assert!(showing_hits(Some(&cross("ses")))); + // 前後の空白は語の一部ではないが、語そのものは残っている。 + assert!(showing_hits(Some(&cross(" ses ")))); + } + + #[test] + fn 結果の行は二段ぶんの高さを取る() { + // 1 件は 2 段(仕様 §6)。1 段目と 2 段目の高さに、上下の余白を足す。 + // 余白は PAD_TOP 9 + PAD_BOTTOM 10 = 19pt で、倍率 2 なら 38 画素。 + assert_eq!(across_block_height(2.0, 34.0, 32.0), 38.0 + 34.0 + 32.0); + assert_eq!(across_block_height(1.0, 17.0, 16.0), 19.0 + 17.0 + 16.0); + // 3 段目(枝)のぶんは取らない。結果の行に枝は出さない。 + let three = 19.0 + 17.0 + 16.0 + 13.0; + assert!(across_block_height(1.0, 17.0, 16.0) < three); + } + + #[test] + fn 走っているあいだは当たりが無いと言わない() { + assert_eq!(scan_note(true, false, 0), ScanNote::Searching); + assert_eq!(scan_note(false, true, 0), ScanNote::Searching); + assert_eq!(scan_note(false, false, 0), ScanNote::NoMatch); + assert_eq!(scan_note(true, false, 3), ScanNote::None); + assert_eq!(scan_note(false, false, 3), ScanNote::None); + } + + #[test] + fn 貼り付けた語の改行は空白になる() { + let mut a = CrossSearch::default(); + a.query.push_str("ab "); + a.push_pasted("cd\r\nef\tg\n"); + assert_eq!(a.query, "ab cd efg "); + } + + #[test] + fn 変換中は窓の幅から文字列のぶんを先に取り分ける() { + // 変換中でなければ、幅は全部が語のもの。 + assert_eq!(split_box_width(100.0, false, 56.0), (100.0, 0.0)); + // 広い窓は、指定のぶんだけ変換中へ割く。 + assert_eq!(split_box_width(100.0, true, 56.0), (44.0, 56.0)); + // 狭い窓でも、語の幅が負にならない。 + assert_eq!(split_box_width(30.0, true, 56.0), (0.0, 30.0)); + assert_eq!(split_box_width(-5.0, true, 56.0), (0.0, 0.0)); + } + + #[test] + fn 変換中の文字列は語が使わなかった幅をすべて使える() { + let (query_w, _) = split_box_width(100.0, true, 56.0); + // 短い語なら、取り分ではなく実際の余りが残る。 + assert_eq!(preedit_room(100.0, 20.0), 80.0); + // 語が予算いっぱいなら、残るのはちょうど取り分。 + assert_eq!(preedit_room(100.0, query_w), 56.0); + // 負にならない。 + assert_eq!(preedit_room(100.0, 130.0), 0.0); + assert_eq!(preedit_room(-5.0, 0.0), 0.0); + } + + #[test] + fn 件が置けない高さでも理由を出す() { + assert_eq!(across_note_text(true, &ScanNote::None), Some("too narrow")); + assert_eq!(across_note_text(false, &ScanNote::None), None); + assert_eq!(across_note_text(true, &ScanNote::NoMatch), Some("no match")); + } + + #[test] + fn 高さに入る件数だけ席を作る() { + // 帯の下(34)から `limit` まで。1 件 50 画素、あいだ 4 画素。 + assert_eq!(across_rows(34.0, 34.0, 50.0, 4.0), 0, "1 件も置けない"); + assert_eq!(across_rows(34.0, 84.0, 50.0, 4.0), 1, "ちょうど 1 件"); + assert_eq!( + across_rows(34.0, 137.0, 50.0, 4.0), + 1, + "2 件目に 1 画素足りない" + ); + assert_eq!(across_rows(34.0, 138.0, 50.0, 4.0), 2); + // 帯が `limit` より下にあっても数えない。 + assert_eq!(across_rows(900.0, 100.0, 50.0, 4.0), 0); + // 高さの無い件を数え続けない。これは描画の経路にある。 + assert_eq!(across_rows(0.0, 1000.0, 0.0, 0.0), 0); + } } #[cfg(test)] diff --git a/src/probe.rs b/src/probe.rs index 4cbaedc..c139bb1 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -116,6 +116,8 @@ pub fn run(out_path: &str) { rename: None, sidebar_width: config.window.sidebar_width, find: None, + across: None, + scan_generation: 0, find_cells: Default::default(), find_current: Default::default(), mouse: Default::default(), @@ -134,7 +136,7 @@ pub fn run(out_path: &str) { pending_since: None, needs_redraw: false, occluded: false, - redactor: crate::secret::Redactor::default(), + redactor: std::sync::Arc::default(), state_dirty: false, state_saved_at: None, shown_title: String::new(), @@ -202,6 +204,61 @@ pub fn run(out_path: &str) { offset: 0, }); } + Ok("across") => { + // 走査は走らせず、結果の一覧の見た目だけを確かめる。 + // 当たりの位置は `find_hit` に出させる。手で数えるとバイトと + // 文字を取り違える。 + let ids: Vec = state.manager.sessions().iter().map(|s| s.id).collect(); + let query = "SES"; + // 走査と同じ針の作り方をする。`str::to_lowercase` は語末シグマで + // `find_hit` の `char` ごとの小文字化と食い違う(`scan::needle`)。 + let needle = crate::scan::needle(query); + let rows: &[(u32, crate::scan::HitKind, &str)] = &[ + // 前後どちらも落とさないと入らない長い行。 + ( + ids[0], + crate::scan::HitKind::Line, + "waiting for the reviewer to confirm that the SES bounce handler retries \ + on a soft failure before we merge the branch", + ), + // 当たりの前後が全角の行。詰め方が文字の境を外さないこと。 + ( + ids[1], + crate::scan::HitKind::Line, + "この PR では SES マッチの一覧を左ペインに出すところまで入れた", + ), + // 名乗った題名に当たった件。行の当たりと見分けが付くこと。 + (ids[2], crate::scan::HitKind::Title, "SES bounce の調査"), + // 走査中に終わったセッション。1 段目が引けない。 + ( + u32::MAX, + crate::scan::HitKind::Line, + "ses: retry queue drained", + ), + ]; + let hits = rows + .iter() + .filter_map(|(id, kind, text)| { + Some(crate::scan::Hit { + id: *id, + kind: *kind, + serial: None, + at: Some(Instant::now()), + text: (*text).to_string(), + hit: crate::scan::find_hit(text, &needle)?, + }) + }) + .collect(); + state.across = Some(crate::CrossSearch { + query: query.into(), + hits, + selected: 1, + ..Default::default() + }); + // 変換中の文字列が窓へ出ることも確かめる。窓に収まらない長さにして、 + // 切れ方(末尾が残ること)と、語が窓いっぱいでも幅が残ることを見る。 + state.preedit = "けんさくごをながくへんかんしているところ".into(); + } Ok("preedit") => { state.preedit = "あっだ".into(); } diff --git a/src/pty.rs b/src/pty.rs index 2a574f8..90ab6f3 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -7,7 +7,7 @@ use std::io::{Read, Write}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::SystemTime; @@ -20,6 +20,7 @@ use crate::osc::{OscEvent, OscScanner}; use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line, Point}; +use crate::lines::{Counting, Ticks}; use crate::session::{grid_text, CommandRecord, SessionId}; use crate::term::{EventProxy, TermSize, UiEvent, UiSender}; @@ -77,6 +78,10 @@ pub struct Spawned { /// 描き直しの有無に左右されず、出力があったことだけを表す。 /// 左ペインで「動いている」印を出すのに使う。 pub activity: Arc, + /// これまでに履歴へ押し出した行の数。行に通し番号を付けるのに使う。 + pub pushed: Arc, + /// 押し出した行数と時刻の対応。通し番号から時刻を引くのに使う。 + pub ticks: Arc>, } #[derive(Debug)] @@ -173,6 +178,8 @@ pub fn spawn(opts: SpawnOptions<'_>, ui_tx: UiSender) -> Result, ui_tx: UiSender) -> Result, ui_tx: UiSender) -> Result, /// 最後に何か読んだ時刻。 activity: Arc, + /// これまでに履歴へ押し出した行の数。 + pushed: Arc, + /// 押し出した行数と時刻の対応。 + ticks: Arc>, + /// スクロールバックの上限(行)。行の数え方の判定に使う。 + scrollback: usize, } fn spawn_reader( @@ -329,7 +347,13 @@ fn spawn_reader( cwd: String, session_key: String, ) { - let ReaderFlags { dirty, activity } = flags; + let ReaderFlags { + dirty, + activity, + pushed, + ticks, + scrollback, + } = flags; thread::Builder::new() .name("termit-pty-read".into()) .spawn(move || { @@ -347,6 +371,8 @@ fn spawn_reader( let mut sent = 0u64; let mut skipped = 0u64; let mut last = std::time::Instant::now(); + // 履歴へ押し出した行の数。この数え手はこのスレッドだけである。 + let mut counted = 0u64; loop { let n = match reader.read(&mut buf) { Ok(0) | Err(_) => { @@ -376,20 +402,56 @@ fn spawn_reader( let bytes = &buf[..n]; let events = scanner.feed(bytes); let mut out: Vec = Vec::new(); + // 読む前の押し出し総数。目盛りを打つかどうかの判定に使う。 + let before = counted; + // 読み始めのカーソル行。この読みが作る行はそこから始まるので、 + // 目盛りもそこへ打つ。1 回の読みで `Counting` を何度も作るから、 + // 取るのは最初のものからである(錠の中で読む)。 + let mut start_line: Option = None; { let mut term = term.lock(); let mut pos = 0usize; // OSC の終端ごとに区切って流し、その時点の画面を読む。 for (end, ev) in events { - parser.advance(&mut *term, &bytes[pos..end]); + let mut counting = Counting::new(&mut term, counted, scrollback); + start_line.get_or_insert_with(|| counting.start_line()); + parser.advance(&mut counting, &bytes[pos..end]); + counted = counting.finish(); pos = end; if let Some(record) = tracker.on_event(id, &ev, &term) { out.push(UiEvent::Command(id, record)); } out.push(UiEvent::Osc(id, ev)); } - parser.advance(&mut *term, &bytes[pos..]); + let mut counting = Counting::new(&mut term, counted, scrollback); + start_line.get_or_insert_with(|| counting.start_line()); + parser.advance(&mut counting, &bytes[pos..]); + counted = counting.finish(); + // 押し出した総数は、グリッドと同じ錠の下で公開する。原子なので + // 錠は増えない。錠を手放してから書くと、その隙間に走査スレッドが + // 錠を取り、進んだグリッドと古い総数を見てしまう。そのセッションの + // 通し番号が 1 読みぶん(64KiB なら数千行)低く出て、`time_of` が + // ずっと古い印を返す。いま流れるのを見ていた行が、理由の分からない + // まま一覧の下へ沈むか `truncate` で切られる。 + pushed.store(counted, Ordering::Relaxed); } + // 時刻は、錠を手放してから 1 読みにつき 1 回だけ残す。 + // 読み手は `term` を持ったまま `ticks` を取らない(走査もその順を守る)。 + // + // 目盛りへ入れるのは読む前の数に読み始めのカーソル行を足した番号である。 + // 読んだ後の数を入れると、この読みで出た行の番号がどれも目盛りより + // 大きくなり、一つ前の目盛り(間が空いていれば何分も前の時刻)に + // 落ちてしまう。読む前の数そのものだと、逆に 1 画面ぶん早く打つ。 + // 1 行も押し出していない読みを渡すかどうかは `Ticks` が決める。 + ticks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .mark_read( + before, + counted, + start_line.unwrap_or(0), + std::time::Instant::now(), + ); for ev in out { if ui_tx.send_event(ev).is_err() { return; diff --git a/src/render.rs b/src/render.rs index 1d9f0b2..c792272 100644 --- a/src/render.rs +++ b/src/render.rs @@ -8,6 +8,7 @@ //! 全角文字が別のフォントへ落ちたときに以降の桁がずれる。 use std::collections::HashMap; +use std::ops::Range; use std::sync::Arc; use alacritty_terminal::vte::ansi::Rgb; @@ -609,6 +610,50 @@ impl Renderer { } } + /// 幅に収まるよう、当たりを残して前後から落とす。落とした側に省略記号を付ける。 + /// + /// `hit` は `text` の**バイト**位置。返す範囲は、返す文字列の中での当たりの + /// **バイト**位置で、先頭の省略記号のぶんだけずれる。当たりが残せる幅より + /// 長ければ、収まる分だけに切って返す。 + pub fn fit_center( + &mut self, + text: &str, + style: TextStyle, + max_px: f32, + hit: Range, + ) -> (String, Range) { + if max_px <= 0.0 || text.is_empty() { + return (String::new(), 0..0); + } + let full = self.measure_px(text, style); + if full <= max_px { + return (text.to_string(), hit); + } + let chars: Vec = text.chars().collect(); + // 文字の添字からバイト位置への表。末尾の次も持つ。 + let mut byte_at: Vec = text.char_indices().map(|(i, _)| i).collect(); + byte_at.push(text.len()); + // バイト位置を文字の添字へ直す。窓の計算は文字の添字で行う。 + let hit_chars = text[..hit.start].chars().count()..text[..hit.end].chars().count(); + let mut keep = ((chars.len() as f32) * (max_px / full)).floor() as usize; + keep = keep.min(chars.len().saturating_sub(1)); + loop { + let candidate = build_window(text, &chars, &byte_at, &hit_chars, &hit, keep); + if self.measure_px(&candidate.0, style) <= max_px || keep == 0 { + // もう 1 文字入るなら入れる。 + if keep + 1 < chars.len() { + let more = build_window(text, &chars, &byte_at, &hit_chars, &hit, keep + 1); + if self.measure_px(&more.0, style) <= max_px { + keep += 1; + continue; + } + } + return candidate; + } + keep -= 1; + } + } + /// その大きさの 1 行の高さ。 pub fn line_height_px(&self, size_pt: f32) -> f32 { (size_pt * self.scale * 1.30).round() @@ -1114,6 +1159,34 @@ fn build(chars: &[char], keep: usize, drop_tail: bool) -> String { } } +/// 当たりを中心に `keep` 字を残し、落とした側に省略記号を付ける。 +/// +/// `hit_chars` は文字の添字、`hit` は `text` のバイト位置。戻りの範囲は +/// 組み立てた文字列のバイト位置で、残した窓に収まる分に切ってある。 +fn build_window( + text: &str, + chars: &[char], + byte_at: &[usize], + hit_chars: &Range, + hit: &Range, + keep: usize, +) -> (String, Range) { + let (win, head, tail) = crate::scan::window_around(chars, hit_chars.clone(), keep); + let (from, to) = (byte_at[win.start], byte_at[win.end]); + let mut s = String::new(); + if head { + s.push('…'); + } + let shift = s.len(); + s.push_str(&text[from..to]); + if tail { + s.push('…'); + } + let start = hit.start.clamp(from, to) - from + shift; + let end = hit.end.clamp(from, to) - from + shift; + (s, start..end) +} + fn rgba(c: Rgb, a: f32) -> [f32; 4] { [ c.r as f32 / 255.0, @@ -1158,7 +1231,7 @@ fn has_family(fs: &FontSystem, name: &str) -> bool { #[cfg(test)] mod tests { - use super::{char_cols, GlyphCache, GlyphKey, STYLES}; + use super::{build_window, char_cols, GlyphCache, GlyphKey, STYLES}; #[test] fn 全角文字は二桁を占める() { @@ -1212,4 +1285,18 @@ mod tests { } } } + + #[test] + fn 当たりを残した文字列では省略記号のぶんだけ当たりの位置がずれる() { + let text = "あいうえお SES かきくけこ"; + let chars: Vec = text.chars().collect(); + let mut byte_at: Vec = text.char_indices().map(|(i, _)| i).collect(); + byte_at.push(text.len()); + let hit = text.find("SES").unwrap(); + let hit = hit..hit + 3; + let hit_chars = 6..9; + let (s, range) = build_window(text, &chars, &byte_at, &hit_chars, &hit, 7); + assert!(s.starts_with('…') && s.ends_with('…')); + assert_eq!(&s[range], "SES"); + } } diff --git a/src/scan.rs b/src/scan.rs new file mode 100644 index 0000000..203f9d9 --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,684 @@ +//! 全セッションのスクロールバックを走り、当たりを新しい順に並べる。 +//! +//! 1 行の中の当たりと、当たりを中心にした詰め方もここに置く。 +//! +//! 左ペインは 200pt ほどの幅しかなく、結果の 1 行には 20 字前後しか載らない。 +//! 末尾を落とす `fit_tail` も先頭を落とす `fit_head` も、探した語そのものを +//! 落としかねない。そこで当たりを残して、前後から落とす。 +//! +//! 単位に注意する。`find_hit` が返すのは元の文字列の**バイト**位置、 +//! `window_around` が受け取り返すのは**文字**の添字である。 + +use std::cmp::Ordering; +use std::ops::Range; +use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::Line; +use alacritty_terminal::sync::FairMutex; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::TermMode; +use alacritty_terminal::Term; + +use crate::lines::{self, Ticks}; +use crate::secret::Redactor; +use crate::session::SessionId; +use crate::term::EventProxy; + +/// 大文字小文字を無視した素の一致。`needle_lower` は呼ぶ側が小文字にして渡す。 +/// +/// 返す範囲は `text` の**バイト**位置。小文字にすると長さが変わる字('İ' など)が +/// あるので、小文字側のバイト位置から元のバイト位置へ戻す対応表を作る。 +pub fn find_hit(text: &str, needle_lower: &str) -> Option> { + if needle_lower.is_empty() { + return None; + } + let mut lower = String::with_capacity(text.len()); + // 小文字側の各バイトが、元の文字のどこから(start)どこまで(end)かを持つ。 + let mut start_at: Vec = Vec::with_capacity(text.len()); + let mut end_at: Vec = Vec::with_capacity(text.len()); + for (i, c) in text.char_indices() { + let end = i + c.len_utf8(); + for lc in c.to_lowercase() { + for _ in 0..lc.len_utf8() { + start_at.push(i); + end_at.push(end); + } + lower.push(lc); + } + } + let at = lower.find(needle_lower)?; + let from = start_at[at]; + let to = end_at[at + needle_lower.len() - 1]; + Some(from..to) +} + +/// 当たりを中心に `keep` 字だけ残す範囲を返す。添字はすべて**文字**。 +/// +/// 当たりの前後へ均等に伸ばし、片側が尽きたら残りをもう片側へ回す。 +/// 当たりが `keep` より長ければ、当たりの頭から `keep` 字を残す。 +/// 戻り値の 2 つの真偽は、頭を落としたか・尾を落としたか。 +pub fn window_around(chars: &[char], hit: Range, keep: usize) -> (Range, bool, bool) { + let len = chars.len(); + if keep >= len { + return (0..len, false, false); + } + let hit_len = hit.end.saturating_sub(hit.start); + let before = keep.saturating_sub(hit_len) / 2; + let start = hit.start.saturating_sub(before).min(len - keep); + let end = start + keep; + (start..end, start > 0, end < len) +} + +/// 走査するセッション 1 本ぶんの持ち物。 +/// +/// `Term` は `Arc` の複製だけを受け取る。中身を写してから走る案は採らない。 +/// 14 本 × 10000 行 × 80 桁で 10M 文字を超え、常駐 85MB の看板に合わない(仕様 §10)。 +pub struct Target { + pub id: SessionId, + pub term: Arc>>, + /// これまでに履歴へ押し出した行の数。行位置に足すと通し番号になる。 + pub pushed: Arc, + /// 通し番号から時刻を引く目盛り。 + pub ticks: Arc>, + pub name: String, + pub title: Option, + pub branch: Option, +} + +/// 何に当たったか。行のほかに、左ペインが出している 3 つの手がかりも探す(仕様 §7)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HitKind { + Line, + Name, + Title, + Branch, +} + +/// 1 件の当たり。1 件は 1 当たりで、セッション単位にはまとめない(仕様 §5)。 +#[derive(Debug, Clone)] +pub struct Hit { + pub id: SessionId, + pub kind: HitKind, + /// 当たった行の通し番号。名前・題名・枝と、代替画面の行なら `None`。 + pub serial: Option, + /// その行が現れた時刻。行でない件(名前・題名・枝)だけが `None` である。 + /// + /// `Ticks` はどの行にも必ず時刻を答えるので、行の件が `None` になることは無い。 + pub at: Option, + /// 伏せ字を通したあとの行の文字列。 + pub text: String, + /// `text` の中の当たりの**バイト**位置。 + pub hit: Range, +} + +/// 走査の上限。 +#[derive(Debug, Clone, Copy)] +pub struct Limits { + /// 1 本あたりの採用数。1 本の当たりで一覧を埋めさせない(仕様 §8)。 + pub per_session: usize, + /// 全体の採用数。 + pub total: usize, + /// 錠を放すまでに見る行数。 + pub chunk: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + per_session: 20, + total: 200, + chunk: 256, + } + } +} + +/// 打った語から `find_hit` に渡す針を作る。 +/// +/// 針も 1 文字ずつ小文字にする。`find_hit` は干し草を `char::to_lowercase` で +/// 小文字にするので、こちらだけ `str::to_lowercase` を使うと食い違う。 +/// ギリシャ語の語末シグマがそれで、`str` は語末の 'Σ' を 'ς' にするが +/// `char` は必ず 'σ' にする。打った語が黙って当たらなくなる。 +pub fn needle(query: &str) -> String { + query.chars().flat_map(char::to_lowercase).collect() +} + +/// 全セッションのスクロールバックを、新しい方から探す。 +/// +/// 走るのは別スレッドである(仕様 §10)。1 フレームの余白は 0.3ms ほどしかなく、 +/// 10000 行の走査はそこに入らない。 +pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits) -> Vec { + let needle = needle(query); + if needle.trim().is_empty() { + return Vec::new(); + } + let mut hits = Vec::new(); + for target in targets { + scan_one(target, &needle, redactor, limits, &mut hits); + } + // 安定な並べ替えなので、同じ時刻のあいだは走った順=本ごとの新しい順が残る。 + hits.sort_by(newest_first); + hits.truncate(limits.total); + hits +} + +/// 一覧の並び。名前・題名・枝を先頭に置き、残りを新しい順に並べる。 +/// +/// 先頭に置くのは、それが時刻を持たないからではなく、行ではないからである。 +/// 時刻を見るのは行の件どうしだけで、そこには必ず時刻が付いている +/// (`Ticks::time_of` は答えを返さないことがない)。 +fn newest_first(a: &Hit, b: &Hit) -> Ordering { + let pinned = |h: &Hit| h.kind != HitKind::Line; + pinned(b).cmp(&pinned(a)).then_with(|| b.at.cmp(&a.at)) +} + +/// 1 本を走る。名前・題名・枝を見てから、行を遡る。 +fn scan_one( + target: &Target, + needle: &str, + redactor: &Redactor, + limits: Limits, + out: &mut Vec, +) { + // 名前・題名・枝は 1 本につき 3 つしかないので、`per_session` には数えない。 + // 上限は「よく喋る本が一覧を埋めてしまう」ことへの備えである(仕様 §8)。 + for (kind, text) in [ + (HitKind::Name, Some(target.name.as_str())), + (HitKind::Title, target.title.as_deref()), + (HitKind::Branch, target.branch.as_deref()), + ] { + let Some((text, hit)) = text.and_then(|t| matched(redactor, t, needle)) else { + continue; + }; + out.push(Hit { + id: target.id, + kind, + serial: None, + at: None, + text, + hit, + }); + } + scan_lines(target, needle, redactor, limits, out); +} + +/// 行を新しい方から遡る。`limits.chunk` 行ごとに錠を放す。 +/// +/// 錠は 2 つある。`term` は pty の読み手が 64KiB の読みごとに取るので、 +/// 10000 行を取りっぱなしで走れば、その本の出力そのものが止まる(仕様 §10)。 +/// `ticks` は読み手が 1 読みにつき 1 回取る。読み手は `term` を持ったまま +/// `ticks` を取らないので、こちらもその順を守る。塊を読み終えて `term` を +/// 放してから、その塊ぶんの時刻をまとめて引く。 +fn scan_lines( + target: &Target, + needle: &str, + redactor: &Redactor, + limits: Limits, + out: &mut Vec, +) { + // 0 行ずつ走ると塊が進まない。 + let chunk = limits.chunk.max(1); + // 直前に採った行。全画面 UI の描き直しは同じ行を何度も履歴へ入れるので、 + // 続けて同じ文が当たるぶんは 1 件にまとめる(仕様 §8)。見ているのは行の + // 並びではなく一覧に並ぶ文なので、当たらない行を挟んでいても畳む。 + let mut last: Option = None; + let mut taken = 0usize; + // 次に見る行の通し番号。行位置ではなく通し番号で覚える。 + // 塊のあいだに出力が流れると、同じ行位置はもう同じ行を指さない。 + let mut next: Option = None; + // 走り始めた時点でいちばん古かった行の通し番号。ここまで遡ったら終わり。 + let mut floor = i64::MIN; + loop { + if taken >= limits.per_session { + return; + } + // この塊で当たったぶん。時刻は錠を放してから付ける。 + let mut found: Vec<(Option, String, Range)> = Vec::new(); + // この塊の先にまだ遡る行があるか。 + let more; + { + let term = target.term.lock(); + // 代替画面に履歴は無い(`history_size()` が 0 になる)。`vim` や `less` が + // 開いているあいだは、いま出ている画面しか探せない。穴として受け入れる + // (仕様 §7)。そのうえ `pushed` は代替画面では進まないので、この画面の行に + // 通し番号を付けると本画面の無関係な履歴行を指してしまう。付けずに置く。 + // 番号の無い件は、そのセッションへ切り替えて `⌘F` に語を渡すだけになる。 + let alt = term.mode().contains(TermMode::ALT_SCREEN); + let grid = term.grid(); + let history = grid.history_size(); + let top = grid.screen_lines() as i32 - 1; + // 行位置に足して通し番号にする数。グリッドと同じ錠の下で読む。 + // 読み手も錠の中で書くので、この総数は手に持ったグリッドのものである。 + let pushed = target.pushed.load(Relaxed); + let mut at = match next { + None => { + floor = lines::serial(pushed, -(history as i32)); + lines::serial(pushed, top) + } + Some(at) => at, + }; + // 覚えた行がもう履歴に無ければ(流れ去った・画面消去で履歴が縮んだ)、 + // そこで遡りを止める。 + let Some(mut line) = lines::line_of(at, pushed, history) else { + break; + }; + if line > top { + // 画面が小さくなっていれば、覚えた行はもう画面の外にある。 + line = top; + at = lines::serial(pushed, line); + } + let bottom = -(history as i32); + let mut text = String::with_capacity(grid.columns()); + let mut walked = 0usize; + while walked < chunk && taken < limits.per_session && line >= bottom && at >= floor { + let row = line; + let row_serial = at; + line -= 1; + at -= 1; + walked += 1; + + row_text(grid, row, &mut text); + if text.is_empty() { + continue; + } + let Some((shown, hit)) = matched(redactor, &text, needle) else { + continue; + }; + if last.as_deref() == Some(shown.as_str()) { + continue; + } + last = Some(shown.clone()); + found.push(((!alt).then_some(row_serial), shown, hit)); + taken += 1; + } + next = Some(at); + more = line >= bottom && at >= floor; + } + // 錠を放してから時刻を引く。`term` を持ったまま `ticks` は取らない。 + if !found.is_empty() { + let ticks = target + .ticks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for (serial, text, hit) in found { + out.push(Hit { + id: target.id, + kind: HitKind::Line, + serial, + // 番号の引けない行には、この本が最後に押し出した時刻を付ける。 + // 行には必ず時刻が付く。`None` になるのは行でない件だけである。 + at: Some(match serial { + Some(serial) => ticks.time_of(serial), + None => ticks.newest(), + }), + text, + hit, + }); + } + } + if !more { + return; + } + } +} + +/// グリッドの 1 行を文字列にする。行末の空白は落とす。 +/// +/// 全角の後ろ半分は実体を持たないコマなので飛ばす(`session.rs` の `grid_text` と +/// 同じ規則)。走査と、選んで飛ぶときの確かめが同じ読み方をするよう、ここに 1 つだけ置く。 +/// +/// `Flags::WRAPLINE` は見ないので、折り返しを跨ぐ語は当たらない。`⌘F` の渡す先 +/// (alacritty の `search_next`)はグリッドを流れとして歩くので見つける。穴として +/// 受け入れて README に書いた。繋いで読むには、当たったバイト位置をどの行へ戻すか +/// という別の工事が要る。 +pub fn row_text( + grid: &alacritty_terminal::grid::Grid, + row: i32, + out: &mut String, +) { + out.clear(); + for cell in &grid[Line(row)][..] { + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + out.push(cell.c); + } + while out.ends_with(' ') { + out.pop(); + } +} + +/// 伏せ字を通してから当たりを探す。返すのは伏せたあとの文字列と、その中の位置。 +/// +/// 先に素の文字列で見るのは速さのためである。`Redactor` は式を 10 本ほど走らせる。 +/// 14 本 × 10000 行の全部に掛ければ秒の単位になるので、当たらない行は式を +/// 1 本も走らせずに捨てる。 +/// +/// 当たった行だけ伏せ字を通し、伏せたあとの文字列でもう一度探す。鍵の中にしか +/// ない当たりはここで消える。端末で `••••` に見えている鍵が、結果の一覧に +/// 素のまま出ることはない(仕様 §6)。 +pub(crate) fn matched( + redactor: &Redactor, + text: &str, + needle: &str, +) -> Option<(String, Range)> { + find_hit(text, needle)?; + let (shown, _) = redactor.redact(text); + let hit = find_hit(&shown, needle)?; + Some((shown, hit)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ScreenConfig; + use crate::term::{TermSize, UiSender}; + use alacritty_terminal::event::WindowSize; + use alacritty_terminal::term::TermMode; + use alacritty_terminal::vte::ansi::{Processor, StdSyncHandler}; + use std::sync::mpsc::channel; + use std::time::Duration; + + #[test] + fn 語末のシグマを文字ごとに小文字にする() { + assert_eq!(needle("ΑΣ"), "ασ"); + assert_eq!(needle("SES"), "ses"); + } + + const COLS: usize = 60; + const ROWS: usize = 6; + + fn make_term(cols: usize, lines: usize, scrollback: usize) -> Term { + let (pty_tx, _pty_rx) = channel(); + let (ui_tx, _ui_rx) = channel(); + let size = TermSize::new(cols, lines); + let ws = Arc::new(FairMutex::new(WindowSize { + num_lines: lines as u16, + num_cols: cols as u16, + cell_width: 8, + cell_height: 16, + })); + let proxy = EventProxy::new(1, pty_tx, UiSender::Channel(ui_tx), ws); + crate::term::new_term(size, scrollback, proxy) + } + + /// 台本を流した `Target` を 1 本作る。 + /// + /// `at` を渡すとその時刻の目盛りを 1 つだけ置く。目盛りが 1 つならどの行も + /// その時刻になるので、本どうしの新しさを比べるのに足りる。`None` なら + /// 目盛りの無い本になり、行の時刻は `Ticks` の作った時刻になる。 + /// 押し出した行数は走査では読むだけなので、試験では 0 のままにする。 + fn target(id: SessionId, play: &[u8], at: Option) -> Target { + let mut term = make_term(COLS, ROWS, 10_000); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, play); + let mut ticks = Ticks::new(); + if let Some(at) = at { + ticks.mark_read(0, 1, 0, at); + } + Target { + id, + term: Arc::new(FairMutex::new(term)), + pushed: Arc::new(AtomicU64::new(0)), + ticks: Arc::new(Mutex::new(ticks)), + name: String::new(), + title: None, + branch: None, + } + } + + /// 伏せる式を 1 つも持たない `Redactor`。行の走りだけを見たいとき。 + fn plain() -> Redactor { + Redactor::new(&[]).expect("式が無ければ組み立てられる") + } + + /// 行の列を台本にする。 + fn play(lines: &[&str]) -> Vec { + let mut out = String::new(); + for line in lines { + out.push_str(line); + out.push_str("\r\n"); + } + out.into_bytes() + } + + /// 1 行ずつ違う文の台本。連続の畳み込みに掛からないようにする。 + fn numbered(prefix: &str, n: usize) -> Vec { + let lines: Vec = (1..=n).map(|i| format!("{prefix} {i}")).collect(); + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + play(&refs) + } + + #[test] + fn 新しい順に並ぶ() { + let t0 = Instant::now(); + let a = target(1, &play(&["古い ses の話"]), Some(t0)); + let b = target( + 2, + &play(&["新しい ses の話"]), + Some(t0 + Duration::from_secs(60)), + ); + let hits = scan(&[a, b], "ses", &plain(), Limits::default()); + assert_eq!(hits.len(), 2, "{hits:?}"); + assert_eq!(hits[0].id, 2, "新しい本が先に来る"); + assert_eq!(hits[1].id, 1); + } + + #[test] + fn 名前は新しい行より先に来る() { + // 行の時刻がいくら新しくても、名前・題名・枝の件を押し下げない。 + let t0 = Instant::now(); + let mut named = target(1, &play(&["ses 新しい行"]), Some(t0)); + named.name = "ses の本".to_string(); + let hits = scan( + std::slice::from_ref(&named), + "ses", + &plain(), + Limits::default(), + ); + assert_eq!(hits.len(), 2, "{hits:?}"); + assert_eq!(hits[0].kind, HitKind::Name); + assert_eq!(hits[1].at, Some(t0)); + } + + #[test] + fn 一度も流れていない本の行にも時刻が付く() { + // 開いたばかりの本は 1 行も押し出していない。目盛りが無くても、 + // `Ticks` が本を開いた時刻を答えるので行から時刻が引ける。 + // 引けないと一覧の並びが決まらない。 + let t = target(1, &play(&["ses one"]), None); + let hits = scan(std::slice::from_ref(&t), "ses", &plain(), Limits::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert!(hits[0].at.is_some(), "{hits:?}"); + assert!(hits[0].serial.is_some(), "本画面の行には通し番号が付く"); + } + + #[test] + fn 一件は一当たり() { + let t = target(1, &play(&["ses one", "ses two", "ses three"]), None); + let hits = scan(&[t], "ses", &plain(), Limits::default()); + assert_eq!(hits.len(), 3, "{hits:?}"); + // 1 本の中でも新しい方から並ぶ。 + assert_eq!(hits[0].text, "ses three"); + assert_eq!(hits[2].text, "ses one"); + assert!( + hits.iter() + .all(|h| h.kind == HitKind::Line && h.serial.is_some()), + "{hits:?}" + ); + } + + #[test] + fn 連続する同じ文は一件にまとめる() { + let t = target(1, &play(&["待っています"; 5]), None); + let hits = scan(&[t], "待って", &plain(), Limits::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + } + + #[test] + fn 一本あたりの上限で他の本の席を残す() { + let a = target(1, &numbered("ses", 50), None); + let b = target(2, &play(&["ses だけの本"]), None); + let limits = Limits { + per_session: 20, + ..Limits::default() + }; + let hits = scan(&[a, b], "ses", &plain(), limits); + assert_eq!(hits.iter().filter(|h| h.id == 1).count(), 20, "{hits:?}"); + assert_eq!(hits.iter().filter(|h| h.id == 2).count(), 1, "{hits:?}"); + } + + #[test] + fn 全体の上限で切り詰める() { + // 全行が当たる語で走らせる。2 本で 40 件ぶん拾えるところを 10 件で切る。 + // 止めるのではなく切り詰める。セッションに時刻の順が無いので、集めてから + // 並べ替えないと「新しい順の 10 件」にならない。1 本あたりの上限が + // 歩く量を抑えている。 + let a = target(1, &numbered("ses a", 50), None); + let b = target(2, &numbered("ses b", 50), None); + let limits = Limits { + total: 10, + ..Limits::default() + }; + let hits = scan(&[a, b], "ses", &plain(), limits); + assert_eq!(hits.len(), 10, "{hits:?}"); + } + + #[test] + fn 塊ごとに錠を放しても遡りを続ける() { + // 塊の境目では、覚えた通し番号から行位置を出し直す。4 行ずつ放しても、 + // 一息に走ったときと同じ件数・同じ並びになること。 + let rows = 50; + let t = target(1, &numbered("ses", rows), None); + let limits = Limits { + per_session: 100, + chunk: 4, + ..Limits::default() + }; + let hits = scan(std::slice::from_ref(&t), "ses", &plain(), limits); + assert_eq!(hits.len(), rows, "{hits:?}"); + assert_eq!(hits[0].text, "ses 50", "新しい方から並ぶ"); + assert_eq!(hits[rows - 1].text, "ses 1"); + } + + #[test] + fn 空白だけの語では何も探さない() { + let t = target(1, &play(&["ses one"]), None); + let one = std::slice::from_ref(&t); + assert!(scan(one, "", &plain(), Limits::default()).is_empty()); + assert!(scan(one, " ", &plain(), Limits::default()).is_empty()); + } + + #[test] + fn 伏せ字の中の当たりは出さない() { + let r = Redactor::new(&ScreenConfig::default().redact).expect("既定の式は組み立てられる"); + let t = target(1, &play(&["key is AKIAIOSFODNN7EXAMPLE here"]), None); + let one = std::slice::from_ref(&t); + // 鍵の一部を語にしても当たらない。 + let inside = scan(one, "iosfodnn", &r, Limits::default()); + assert!(inside.is_empty(), "{inside:?}"); + // 同じ行の別の語なら当たるが、行の文字列に鍵は残っていない。 + let hits = scan(one, "key is", &r, Limits::default()); + assert_eq!(hits.len(), 1, "{hits:?}"); + assert!( + !hits[0].text.contains("AKIAIOSFODNN7EXAMPLE"), + "{}", + hits[0].text + ); + assert!(hits[0].text.contains("[redacted]"), "{}", hits[0].text); + // 当たりの位置は、伏せたあとの文字列の中の位置である。 + assert_eq!(&hits[0].text[hits[0].hit.clone()], "key is"); + } + + #[test] + fn 名前と題名と枝も探す() { + let mut t = target(1, b"", None); + t.name = "SESマッチ".to_string(); + t.title = Some("ses の一覧".to_string()); + t.branch = Some("feat/ses".to_string()); + let hits = scan(std::slice::from_ref(&t), "ses", &plain(), Limits::default()); + assert_eq!(hits.len(), 3, "{hits:?}"); + assert_eq!(hits[0].kind, HitKind::Name); + assert_eq!(hits[0].text, "SESマッチ"); + assert_eq!(hits[0].hit, 0..3); + assert_eq!(hits[1].kind, HitKind::Title); + assert_eq!(hits[2].kind, HitKind::Branch); + assert!( + hits.iter().all(|h| h.serial.is_none() && h.at.is_none()), + "{hits:?}" + ); + } + + #[test] + fn 代替画面でも落ちない() { + let mut script = play(&["main hit one", "main hit two"]); + script.extend_from_slice(b"\x1b[?1049h"); + script.extend_from_slice(&play(&["alt hit one", "alt hit two"])); + let t0 = Instant::now(); + let t = target(1, &script, Some(t0)); + // 本画面で押し出した行のぶん、目盛りを 2 つ持たせる。 + let newest = t0 + Duration::from_secs(60); + t.ticks + .lock() + .expect("目盛りの錠") + .mark_read(1, 2, 0, newest); + assert!(t.term.lock().mode().contains(TermMode::ALT_SCREEN)); + assert_eq!( + t.term.lock().grid().history_size(), + 0, + "代替画面に履歴は無い" + ); + // いま出ている画面のぶんだけが出る(仕様 §7)。 + let hits = scan(std::slice::from_ref(&t), "hit", &plain(), Limits::default()); + assert_eq!(hits.len(), 2, "{hits:?}"); + assert!(hits.iter().all(|h| h.text.starts_with("alt")), "{hits:?}"); + // 代替画面の行に通し番号は付けない。`pushed` が進まないので、付けると + // 本画面の無関係な履歴行を指す。時刻は最後の目盛りから取る。 + assert!(hits.iter().all(|h| h.serial.is_none()), "{hits:?}"); + assert!(hits.iter().all(|h| h.at == Some(newest)), "{hits:?}"); + } + + #[test] + fn 大文字小文字を無視して探す() { + assert_eq!(find_hit("この SESマッチ機能", "ses"), Some(7..10)); + assert_eq!(find_hit("ses", "SES"), None); // needle は小文字で渡す約束 + assert_eq!(find_hit("abc", "x"), None); + } + + #[test] + fn 小文字にして長さが変わっても位置を戻せる() { + // 'İ'(U+0130)の小文字は 'i' と結合点の 2 文字で、バイト数が増える。 + // 元の文字列のバイト位置が返ること。 + let t = "aİb"; + assert_eq!(find_hit(t, "b"), Some(t.len() - 1..t.len())); + // 増えた側の途中にかかる一致でも、元の文字の境で返る。 + assert_eq!(find_hit(t, "i"), Some(1..3)); + } + + #[test] + fn 当たりを真ん中に残す() { + let chars: Vec = "0123456789ABCDEFGHIJ".chars().collect(); + // 当たりは 10..12。20 字のうち 8 字だけ残すなら、前後を均等に落とす。 + let (keep, head, tail) = window_around(&chars, 10..12, 8); + assert!(keep.start <= 10 && keep.end >= 12); + assert_eq!(keep.end - keep.start, 8); + assert!(head && tail); + } + + #[test] + fn 当たりが端にあるときは片側だけ落とす() { + let chars: Vec = "0123456789".chars().collect(); + let (keep, head, tail) = window_around(&chars, 0..2, 5); + assert_eq!(keep, 0..5); + assert!(!head && tail); + } + + #[test] + fn 当たりが残す幅より長ければ当たりの頭から残す() { + let chars: Vec = "0123456789".chars().collect(); + let (keep, _, _) = window_around(&chars, 2..9, 3); + assert_eq!(keep, 2..5); + } +} diff --git a/src/search.rs b/src/search.rs index 6bfb9f5..c6f781a 100644 --- a/src/search.rs +++ b/src/search.rs @@ -13,6 +13,16 @@ pub struct ScreenSearch { pub query: String, /// 入力をそのまま正規表現として扱うか。 pub regex_mode: bool, + /// 大文字小文字を区別しないで探すか。 + /// + /// `RegexSearch::new` は smart-case である(大文字を含む語だけ区別する)。 + /// `⌘F` 自身はそれを変えないので、既定は偽のままにする。 + /// + /// 立てるのは、全セッション検索から語を受け取るときだけである。あちらは + /// 無条件に区別しない(`scan::needle`)ので、`SES`・`AWS`・`TODO` のような + /// 大文字を含む語が小文字の行に当たりうる。その件を区別する式で渡すと、 + /// 切り替えて飛んだあと `⌘F` が何も見つけず、強調が黙って点かない(仕様 §9)。 + pub ignore_case: bool, /// いま選んでいる一致。 pub current: Option, /// 正規表現として組み立てられなかった。 @@ -25,6 +35,7 @@ impl ScreenSearch { Self { query: String::new(), regex_mode: false, + ignore_case: false, current: None, invalid: false, regex: None, @@ -39,11 +50,7 @@ impl ScreenSearch { self.regex = None; return; } - let pattern = if self.regex_mode { - self.query.clone() - } else { - escape(&self.query) - }; + let pattern = pattern(&self.query, self.regex_mode, self.ignore_case); match RegexSearch::new(&pattern) { Ok(r) => self.regex = Some(r), Err(_) => { @@ -115,6 +122,23 @@ fn start_point(term: &Term, direction: Direction) -> Point { } } +/// 打った語から、組み立てる式を作る。 +/// +/// `(?i)` を足すのは**打ち消したあと**である。先に足すと `(`・`?`・`)` が +/// 打ち消されて、ただの文字列になってしまう。打った語そのもの(`query`)は +/// 触らないので、帯に出る文字は変わらない。 +fn pattern(query: &str, regex_mode: bool, ignore_case: bool) -> String { + let mut out = if regex_mode { + query.to_string() + } else { + escape(query) + }; + if ignore_case { + out.insert_str(0, "(?i)"); + } + out +} + /// 正規表現の特殊文字を打ち消す。既定では入力をそのままの文字列として探す。 fn escape(s: &str) -> String { let mut out = String::with_capacity(s.len() * 2); @@ -130,6 +154,81 @@ fn escape(s: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::term::{EventProxy, TermSize, UiSender}; + use alacritty_terminal::event::WindowSize; + use alacritty_terminal::sync::FairMutex; + use alacritty_terminal::vte::ansi::{Processor, StdSyncHandler}; + use std::sync::mpsc::channel; + use std::sync::Arc; + + /// 台本を流した端末を 1 つ作る。 + fn fed(script: &str) -> Term { + let (pty_tx, _pty_rx) = channel(); + let (ui_tx, _ui_rx) = channel(); + let (cols, lines) = (60, 8); + let ws = Arc::new(FairMutex::new(WindowSize { + num_lines: lines as u16, + num_cols: cols as u16, + cell_width: 8, + cell_height: 16, + })); + let proxy = EventProxy::new(1, pty_tx, UiSender::Channel(ui_tx), ws); + let mut term = crate::term::new_term(TermSize::new(cols, lines), 1000, proxy); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, script.as_bytes()); + term + } + + #[test] + fn 区別しない旗を立てると式の頭に付く() { + assert_eq!(pattern("SES", false, false), "SES"); + assert_eq!(pattern("SES", false, true), "(?i)SES"); + // 打ち消したあとに付ける。先に付けると括弧まで打ち消される。 + assert_eq!(pattern("a.b", false, true), r"(?i)a\.b"); + assert_eq!(pattern("a(b)", true, true), "(?i)a(b)"); + } + + #[test] + fn 旗を立てた語は小文字の行にも当たる() { + // 全セッション検索から渡された語の経路。あちらは無条件に区別しないので、 + // 大文字を含む語が小文字の行に当たりうる。 + let term = fed("the ses bounce handler\r\n"); + let mut s = ScreenSearch::new(); + s.query = "SES".into(); + s.ignore_case = true; + s.rebuild(); + assert!( + s.step(&term, Direction::Left).is_some(), + "区別しないので小文字の行に当たる" + ); + } + + #[test] + fn 旗を立てなければ大文字を含む語は区別する() { + // `⌘F` 自身の振る舞いは変えない。`RegexSearch::new` の smart-case のままである。 + let term = fed("the ses bounce handler\r\n"); + let mut s = ScreenSearch::new(); + s.query = "SES".into(); + s.rebuild(); + assert!( + s.step(&term, Direction::Left).is_none(), + "大文字を含む語は区別するので当たらない" + ); + let mut lower = ScreenSearch::new(); + lower.query = "Ses".into(); + lower.rebuild(); + assert!( + lower.step(&term, Direction::Left).is_none(), + "大文字が 1 つでも有れば区別する" + ); + let mut plain = ScreenSearch::new(); + plain.query = "ses".into(); + plain.rebuild(); + assert!( + plain.step(&term, Direction::Left).is_some(), + "小文字だけなら区別しない" + ); + } #[test] fn 特殊文字を打ち消す() { diff --git a/src/secret.rs b/src/secret.rs index 69e1ea8..365b032 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -16,8 +16,7 @@ use std::ops::Range; use regex::Regex; -/// 試験で伏せた跡に置く文字列。画面では `MASK_CHAR` を 1 コマずつ置く。 -#[cfg(test)] +/// 伏せた跡に置く文字列。画面では `MASK_CHAR` を 1 コマずつ置く。 const REDACTED: &str = "[redacted]"; /// 設定の式をまとめて持つ。起動時に 1 度だけ組み立てる。 @@ -106,8 +105,10 @@ impl Redactor { out } - /// 伏せた文字列を組み立てる。読みやすさのため、試験でだけ使う。 - #[cfg(test)] + /// 伏せた文字列を組み立てる。 + /// + /// 画面はコマごとに `MASK_CHAR` を置くので、こちらを通らない。読み手は + /// 全セッション検索の結果の行(`scan.rs`)と、この作りを確かめる試験である。 pub fn redact(&self, text: &str) -> (String, usize) { let spans = self.spans(text); let mut out = String::with_capacity(text.len()); diff --git a/src/session.rs b/src/session.rs index 46b0b1d..48ffaee 100644 --- a/src/session.rs +++ b/src/session.rs @@ -12,6 +12,7 @@ use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::cell::Flags; use crate::config::{self, Config, ExpandError, Vars}; +use crate::lines::Ticks; use crate::pty::{self, PtyHandle, SpawnError}; use crate::term::{now_ms, EventProxy, TermSize, UiSender}; @@ -43,6 +44,12 @@ pub struct Session { pub dirty: Arc, /// 最後に出力があった時刻(起動からのミリ秒)。 pub activity: Arc, + /// これまでに履歴へ押し出した行の数。行に通し番号を付けるのに使う。 + /// + /// 読むのは左ペインの横断検索で、走査スレッドへ `Arc` の複製を渡す。 + pub pushed: Arc, + /// 押し出した行数と時刻の対応。通し番号から時刻を引くのに使う。 + pub ticks: Arc>, /// 左ペインに「動いている」と描いてあるか。 /// /// 描いた状態を覚えておき、変わるときだけ描き直す。 @@ -650,6 +657,8 @@ impl Manager { window_size: spawned.window_size, dirty: spawned.dirty, activity: spawned.activity, + pushed: spawned.pushed, + ticks: spawned.ticks, shown_working: false, title_working: None, blocked: false, diff --git a/src/term.rs b/src/term.rs index cd262ed..37636b5 100644 --- a/src/term.rs +++ b/src/term.rs @@ -91,6 +91,11 @@ pub enum UiEvent { Osc(SessionId, crate::osc::OscEvent), /// 組み立てが終わったコマンドの記録。 Command(SessionId, crate::session::CommandRecord), + /// 横断検索の走査が終わった。第 1 要素は走査の世代。 + /// + /// これだけ `SessionId` を持たない。全セッションを走った結果なので、 + /// どれか 1 本のものではない。 + Scanned(u64, Vec), } /// `Term` からのイベントを受け、必要な応答を PTY へ書き戻す。 diff --git a/src/throughput.rs b/src/throughput.rs index 8310eb1..ba9fa6c 100644 --- a/src/throughput.rs +++ b/src/throughput.rs @@ -2,7 +2,8 @@ //! //! alacritty の vtebench は端末の中で走らせて「PTY を読む速さ」だけを測る。 //! ここではその負荷を真似た列を自分で作り、実際の読み取り経路 -//! (`OscScanner` の走査 → `vte` の解釈 → グリッドへの書き込み)を通す。 +//! (`OscScanner` の走査 → `vte` の解釈 → 行数の数え上げ → グリッドへの書き込み) +//! を通す。 //! 走査と解釈を別々に計るので、どちらを削ればよいかが分かる。 use std::sync::Arc; @@ -12,6 +13,7 @@ use alacritty_terminal::event::WindowSize; use alacritty_terminal::sync::FairMutex; use alacritty_terminal::vte::ansi::Processor; +use crate::lines::Counting; use crate::osc::OscScanner; use crate::term::{new_term, EventProxy, TermSize, UiSender}; @@ -80,6 +82,7 @@ fn feed(payload: &[u8]) -> (Duration, Duration) { let mut scanner = OscScanner::new(); let mut scan = Duration::ZERO; let mut parse = Duration::ZERO; + let mut counted = 0u64; for chunk in payload.chunks(CHUNK) { let t = Instant::now(); let events = scanner.feed(chunk); @@ -87,10 +90,14 @@ fn feed(payload: &[u8]) -> (Duration, Duration) { let t = Instant::now(); let mut pos = 0usize; for (end, _ev) in events { - parser.advance(&mut term, &chunk[pos..end]); + let mut counting = Counting::new(&mut term, counted, SCROLLBACK); + parser.advance(&mut counting, &chunk[pos..end]); + counted = counting.finish(); pos = end; } - parser.advance(&mut term, &chunk[pos..]); + let mut counting = Counting::new(&mut term, counted, SCROLLBACK); + parser.advance(&mut counting, &chunk[pos..]); + counted = counting.finish(); parse += t.elapsed(); } drop(rx);