From 80621616b5e336eb522a85a13a5413d5bca3be1a Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 19:58:22 +0900 Subject: [PATCH 01/30] feat: give cmd-shift-f its own action instead of falling through to find Co-Authored-By: Claude Opus 5 --- src/input.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 ++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/input.rs b/src/input.rs index 06bf9fe..d7ef796 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,15 @@ fn action_from_physical(physical: PhysicalKey, mods: ModifiersState) -> Option Some(Action::FindAcrossSessions), + KeyCode::KeyR => Some(Action::RenameSession), + _ => None, + }; + } let command = match code { KeyCode::KeyN => Some(Action::NewSession), KeyCode::KeyD => Some(Action::Fork), @@ -319,6 +334,44 @@ mod tests { } const NONE: ModifiersState = ModifiersState::empty(); + #[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/main.rs b/src/main.rs index 8cb70ee..774905f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1012,6 +1012,8 @@ impl App { state.search = None; state.find = Some(search::ScreenSearch::new()); } + // 横断検索の箱はまだ無い。キーだけ先に分けてある。 + Action::FindAcrossSessions => {} Action::SearchHistory => { state.picker = None; state.find = None; From 2f54a9b4aa1ffa7669ecc2f179aa41fd3c4cfbb7 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 20:01:22 +0900 Subject: [PATCH 02/30] fix: keep shifted symbol keys working on the physical key path Co-Authored-By: Claude Opus 5 --- src/input.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/input.rs b/src/input.rs index d7ef796..526ec0c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -177,12 +177,17 @@ 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, }; } @@ -334,6 +339,20 @@ 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; From fb2c3ffe68b03a466176c7ee139ce4667207d473 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 20:50:09 +0900 Subject: [PATCH 03/30] 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 --- src/lines.rs | 506 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/pty.rs | 46 ++++- src/session.rs | 11 ++ 4 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 src/lines.rs diff --git a/src/lines.rs b/src/lines.rs new file mode 100644 index 0000000..6087c0f --- /dev/null +++ b/src/lines.rs @@ -0,0 +1,506 @@ +//! 行の通し番号。 +//! +//! 行から時刻を引くための土台である。`Grid::history_size()` はスクロールバックの +//! 上限で頭打ちになるので、1 時間前の行と今の行が同じ番号になってしまう。そこで +//! `Term` へ渡す前に `Handler` を包み、履歴へ押し出した行を自分で数える。 +//! +//! 画面上の行位置 `line` は、新しい行が来るたびに 1 ずつ小さくなる。押し出した +//! 行数 `pushed` を足せば、その行に一度しか付かない番号になる(`serial`)。 +//! 逆に、覚えておいた番号からいまの行位置も出せる(`line_of`)。 +//! +//! 番号を引く側(`serial`・`line_of`・`Ticks::time_of`)の読み手は左ペインの検索で、 +//! まだ入っていない。数える側だけを先に置いている。 + +use std::collections::VecDeque; +use std::time::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 unicode_width::UnicodeWidthChar; + +use crate::term::EventProxy; + +/// 行位置 `line` の通し番号。画面の最上段がちょうど `pushed` になる。 +#[allow(dead_code)] +pub fn serial(pushed: u64, line: i32) -> i64 { + pushed as i64 + i64::from(line) +} + +/// 通し番号 `serial` がいまどの行位置にあるか。履歴から押し出されていれば `None`。 +#[allow(dead_code)] +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 つ積むので、約 30 秒から数分ぶんになる。 +pub const TICKS: usize = 4096; + +/// 通し番号と時刻の対応。古いものから捨てる環である。 +#[derive(Debug, Default)] +pub struct Ticks { + /// `(そのときまでに押し出した行数, 時刻)`。行数の昇順に並ぶ。 + marks: VecDeque<(u64, Instant)>, +} + +impl Ticks { + pub fn new() -> Self { + Self::default() + } + + /// いまの行数と時刻を積む。行数が増えていなければ何もしない。 + pub fn push(&mut self, pushed: u64, at: Instant) { + if self.marks.back().is_some_and(|&(last, _)| last >= pushed) { + return; + } + if self.marks.len() == TICKS { + self.marks.pop_front(); + } + self.marks.push_back((pushed, at)); + } + + /// 通し番号 `serial` の行が現れた時刻。 + /// + /// 目盛りを捨てたぶんの時刻は分からない。残っている最古の時刻を返す。 + /// その行がそれより古いことは確かである。 + #[allow(dead_code)] + pub fn time_of(&self, serial: i64) -> Option { + let after = self + .marks + .partition_point(|&(pushed, _)| (pushed as i64) <= serial); + self.marks.get(after.saturating_sub(1)).map(|&(_, at)| at) + } +} + +/// 履歴へ押し出した行を数えながら `Term` へ中継する。 +/// +/// `Processor::advance` は `Handler` を実装するものを何でも受け取るので、 +/// `Term` の代わりにこれを渡せば、通った escape sequence を数えられる。 +/// +/// 数えるのは、`Term` の中で履歴へ行が積まれる道すべてである。`alacritty_terminal` +/// で履歴が伸びるのは `Grid::scroll_up` を画面の先頭から呼んだときだけで、そこへ +/// 届くのは改行・NEL・SU・行削除・画面消去(ED 2)と、行末の折り返しである。 +/// 折り返しと NEL は `Term` が自分の `linefeed` を呼ぶので、こちらの `linefeed` +/// は通らない。書き込みの側から数えないと取りこぼす。 +/// +/// 上限に達していないあいだは履歴行数の差がそのまま答えなので、どの道を通っても +/// 正確である。上限に達してからは見積もりになり、DECSTBM で画面の一部だけを +/// スクロール範囲にしている本画面では、改行を数え違えることがある。 +/// また、`Term::resize` の折り返し直しは行の位置を動かすので、 +/// 大きさを変えると古い通し番号は少しずれる。 +pub struct Counting<'a> { + inner: &'a mut Term, + pushed: u64, + /// スクロールバックの上限(行)。履歴行数の差が頭打ちになったことを知るのに使う。 + cap: usize, +} + +impl<'a> Counting<'a> { + pub fn new(inner: &'a mut Term, pushed: u64, cap: usize) -> Self { + Self { inner, pushed, cap } + } + + /// これまでに履歴へ押し出した行数。 + pub fn pushed(&self) -> u64 { + self.pushed + } + + /// 押し出しの起きうる呼び出しを、数えながら行う。 + /// + /// 上限に届くまでは履歴行数の差が正確なので、差をそのまま使う。上限に達すると + /// 差が出なくなるので、そのときだけ `guess` 行押し出したものとして数える。 + /// 上限をまたぐ 1 回では差が足りないことがあるので、多いほうを採る。 + fn counted(&mut self, guess: usize, call: impl FnOnce(&mut Term)) { + // 代替画面に履歴は無い。全画面の UI が流す行は数えない。 + if self.alt() { + call(self.inner); + return; + } + let before = self.history(); + call(self.inner); + let after = self.history(); + let diff = after.saturating_sub(before); + let counted = if after >= self.cap { + diff.max(guess) + } else { + diff + }; + self.pushed += counted as u64; + } + + 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() + } + + /// 折り返しの起きうる 1 文字を、数えながら書き込む。 + /// + /// 行末でしか通らないので、`input` の本体から外へ出しておく。 + /// 入れたままにすると、字を流す速さが 1 割ほど落ちる(`--throughput` で確かめた)。 + #[cold] + #[inline(never)] + fn wrapped_input(&mut self, c: char) { + let guess = usize::from(self.folds()); + self.counted(guess, |term| term.input(c)); + } + + /// 画面に何か書いてある行数。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 input(&mut self, c: char) { + // 折り返すのは、行末に折り返しを溜めているときと、行末の 1 桁に入らない + // 全角文字が来たときだけである。1 文字ごとに通る道なので、それ以外は + // 何も読まずに通す。ASCII は必ず 1 桁なので、桁数も幅も見ない。 + let grid = self.inner.grid(); + let may_wrap = grid.cursor.input_needs_wrap + || (!c.is_ascii() + && grid.cursor.point.column.0 + 1 >= grid.columns() + && c.width().is_some_and(|w| w >= 2)); + if may_wrap { + self.wrapped_input(c); + return; + } + self.inner.input(c); + } + + fn put_tab(&mut self, count: u16) { + // 行末のタブは折り返しになる。 + let guess = usize::from(self.wrapping()); + self.counted(guess, |term| term.put_tab(count)); + } + + fn linefeed(&mut self) { + let guess = usize::from(self.at_bottom()); + self.counted(guess, |term| term.linefeed()); + } + + fn newline(&mut self) { + // NEL は改行と同じ道を通る。 + let guess = usize::from(self.at_bottom()); + self.counted(guess, |term| term.newline()); + } + + fn scroll_up(&mut self, lines: usize) { + let guess = lines.min(self.inner.grid().screen_lines()); + self.counted(guess, |term| term.scroll_up(lines)); + } + + fn delete_lines(&mut self, lines: usize) { + // 画面の先頭で消すと、下の行が上へ送られて履歴へ出る。 + let grid = self.inner.grid(); + let guess = if grid.cursor.point.line.0 == 0 { + lines.min(grid.screen_lines()) + } else { + 0 + }; + self.counted(guess, |term| term.delete_lines(lines)); + } + + fn clear_screen(&mut self, mode: ClearMode) { + // ED 2 は本画面では消さずに、書いてあるぶんを履歴へ押し上げる。 + let guess = if matches!(mode, ClearMode::All) && !self.alt() { + self.filled_lines() + } else { + 0 + }; + self.counted(guess, |term| term.clear_screen(mode)); + } + + forward! { + fn set_title(title: Option); + fn set_cursor_style(style: Option); + fn set_cursor_shape(shape: CursorShape); + 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) + } + + /// 台本を端末へ流し、数えた行数と、端末が実際に抱えた履歴行数を返す。 + /// + /// 上限に届くまでは履歴行数が答え合わせになる。 + fn feed(cols: usize, lines: usize, scrollback: usize, script: &[u8]) -> (u64, usize) { + let mut term = make_term(cols, lines, scrollback); + let mut parser: Processor = Processor::new(); + let mut counting = Counting::new(&mut term, 0, scrollback); + parser.advance(&mut counting, script); + let pushed = counting.pushed(); + (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 行削除で押し出された行も数える() { + // 画面の先頭で 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"); + script.extend([b'y'; 73]); // 折り返し + 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(10, 4, 10_000, &script); + assert_eq!(wide, history as u64, "上限に届かなければ履歴行数と一致する"); + let (narrow, capped) = feed(10, 4, 3, &script); + assert_eq!(capped, 3, "狭いほうは上限で止まっている"); + assert_eq!(narrow, wide, "上限に達しても同じ数になる"); + } + + #[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), Some(t1)); + assert_eq!(ticks.time_of(30), Some(t2)); + assert_eq!( + ticks.time_of(5), + Some(t0), + "一番古い目盛りより前なら、残っている最古の時刻を返す" + ); + assert_eq!(Ticks::new().time_of(1), None, "目盛りが無ければ時刻も無い"); + } + + #[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), Some(t0), "先に見た時刻を残す"); + } + + #[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_millis(i)); + } + assert_eq!( + ticks.time_of(0), + Some(t0 + Duration::from_millis(100)), + "溢れた 100 件は捨て、残っている最古の時刻を返す" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 774905f..9946257 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; diff --git a/src/pty.rs b/src/pty.rs index 2a574f8..69ce1d0 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(_) => { @@ -381,15 +407,25 @@ fn spawn_reader( 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); + parser.advance(&mut counting, &bytes[pos..end]); + counted = counting.pushed(); 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); + parser.advance(&mut counting, &bytes[pos..]); + counted = counting.pushed(); } + // 行数と時刻は、錠を手放してから 1 読みにつき 1 回だけ残す。 + pushed.store(counted, Ordering::Relaxed); + ticks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(counted, std::time::Instant::now()); for ev in out { if ui_tx.send_event(ev).is_err() { return; diff --git a/src/session.rs b/src/session.rs index 46b0b1d..5a328fb 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,14 @@ pub struct Session { pub dirty: Arc, /// 最後に出力があった時刻(起動からのミリ秒)。 pub activity: Arc, + /// これまでに履歴へ押し出した行の数。行に通し番号を付けるのに使う。 + /// + /// 読み手は左ペインの検索で、まだ入っていない。 + #[allow(dead_code)] + pub pushed: Arc, + /// 押し出した行数と時刻の対応。通し番号から時刻を引くのに使う。 + #[allow(dead_code)] + pub ticks: Arc>, /// 左ペインに「動いている」と描いてあるか。 /// /// 描いた状態を覚えておき、変わるときだけ描き直す。 @@ -650,6 +659,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, From 428c235ce246233868ccc65876b289d160341e5b Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 21:04:22 +0900 Subject: [PATCH 04/30] 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 --- src/lines.rs | 195 +++++++++++++++++++++++++--------------------- src/pty.rs | 12 +-- src/throughput.rs | 13 +++- 3 files changed, 121 insertions(+), 99 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index 6087c0f..bef8bb8 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -12,7 +12,7 @@ //! まだ入っていない。数える側だけを先に置いている。 use std::collections::VecDeque; -use std::time::Instant; +use std::time::{Duration, Instant}; use alacritty_terminal::grid::{Dimensions, GridCell}; use alacritty_terminal::index::Line; @@ -24,7 +24,6 @@ use alacritty_terminal::vte::ansi::{ ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode, }; use alacritty_terminal::Term; -use unicode_width::UnicodeWidthChar; use crate::term::EventProxy; @@ -44,9 +43,14 @@ pub fn line_of(serial: i64, pushed: u64, history_size: usize) -> Option { i32::try_from(line).ok() } -/// 覚えておく目盛りの数。1 読みにつき 1 つ積むので、約 30 秒から数分ぶんになる。 +/// 覚えておく目盛りの数。1 秒に 1 つ積むので、約 68 分ぶんになる。 pub const TICKS: usize = 4096; +/// 目盛りの間隔。仕様は 1 秒の分解能で足りるとしている。 +/// +/// 1 読みにつき 1 つ積むと、忙しい出力では数分で環を使い切ってしまう。 +const TICK_GAP: Duration = Duration::from_secs(1); + /// 通し番号と時刻の対応。古いものから捨てる環である。 #[derive(Debug, Default)] pub struct Ticks { @@ -59,10 +63,14 @@ impl Ticks { Self::default() } - /// いまの行数と時刻を積む。行数が増えていなければ何もしない。 + /// いまの行数と時刻を積む。 + /// + /// 行数が増えていないか、前の目盛りから 1 秒経っていなければ何もしない。 pub fn push(&mut self, pushed: u64, at: Instant) { - if self.marks.back().is_some_and(|&(last, _)| last >= pushed) { - return; + 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(); @@ -88,55 +96,65 @@ impl Ticks { /// `Processor::advance` は `Handler` を実装するものを何でも受け取るので、 /// `Term` の代わりにこれを渡せば、通った escape sequence を数えられる。 /// -/// 数えるのは、`Term` の中で履歴へ行が積まれる道すべてである。`alacritty_terminal` -/// で履歴が伸びるのは `Grid::scroll_up` を画面の先頭から呼んだときだけで、そこへ -/// 届くのは改行・NEL・SU・行削除・画面消去(ED 2)と、行末の折り返しである。 -/// 折り返しと NEL は `Term` が自分の `linefeed` を呼ぶので、こちらの `linefeed` -/// は通らない。書き込みの側から数えないと取りこぼす。 +/// 数え方は 2 つを合わせる。 /// -/// 上限に達していないあいだは履歴行数の差がそのまま答えなので、どの道を通っても -/// 正確である。上限に達してからは見積もりになり、DECSTBM で画面の一部だけを -/// スクロール範囲にしている本画面では、改行を数え違えることがある。 -/// また、`Term::resize` の折り返し直しは行の位置を動かすので、 -/// 大きさを変えると古い通し番号は少しずれる。 +/// 1 つは履歴行数の差である。上限に届くまでは押し出した行が 1 行ずつ履歴を +/// 伸ばすので、始めと終わりの差がそのまま答えになる。どの道を通っても漏れない。 +/// もう 1 つは行ごとの道での数え上げで、改行・NEL・SU・行削除・画面消去(ED 2)・ +/// 行末のタブを数える。上限に達すると差が出なくなるので、こちらが答えになる。 +/// 多いほうを採れば、どちらの側にいても数が合う。 +/// +/// 数え漏らしと数え過ぎが残る。 +/// +/// - 行末の折り返しは `Term` が自分の `linefeed` を呼ぶので、こちらの `linefeed` +/// を通らない。上限に届くまでは差に出るので数えられるが、上限に達してからは +/// 数えられない。1 文字ごとに見れば数えられるが、それは `input` を 1 割ほど +/// 遅くするので採らない(仕様 §4 の非目標)。 +/// - DECSTBM で画面の一部だけをスクロール範囲にしている本画面では、履歴へ入らない +/// 改行を数えてしまうことがある。 +/// - `Term::resize` の折り返し直しは行の位置を動かすので、大きさを変えると +/// 古い通し番号は少しずれる。 pub struct Counting<'a> { inner: &'a mut Term, pushed: u64, - /// スクロールバックの上限(行)。履歴行数の差が頭打ちになったことを知るのに使う。 - cap: usize, + /// 始めた時点の履歴行数。代替画面にいたなら `None`。 + before: Option, + /// 行ごとの道で数えた行数。 + hooked: u64, } impl<'a> Counting<'a> { - pub fn new(inner: &'a mut Term, pushed: u64, cap: usize) -> Self { - Self { inner, pushed, cap } + pub fn new(inner: &'a mut Term, pushed: u64) -> Self { + // 代替画面の履歴行数は本画面のものではない。差の起点にはしない。 + let before = + (!inner.mode().contains(TermMode::ALT_SCREEN)).then(|| inner.grid().history_size()); + Self { + inner, + pushed, + before, + hooked: 0, + } } - /// これまでに履歴へ押し出した行数。 - pub fn pushed(&self) -> u64 { - self.pushed + /// 数え終えて、押し出した行の合計を返す。 + pub fn finish(self) -> u64 { + let delta = match self.before { + // 途中で画面が入れ替わっていれば、差は本画面のものではない。 + Some(before) if !self.alt() => self.history().saturating_sub(before) as u64, + _ => 0, + }; + self.pushed + delta.max(self.hooked) } /// 押し出しの起きうる呼び出しを、数えながら行う。 /// - /// 上限に届くまでは履歴行数の差が正確なので、差をそのまま使う。上限に達すると - /// 差が出なくなるので、そのときだけ `guess` 行押し出したものとして数える。 - /// 上限をまたぐ 1 回では差が足りないことがあるので、多いほうを採る。 - fn counted(&mut self, guess: usize, call: impl FnOnce(&mut Term)) { + /// `lines` は押し出される行数。カーソルが動く前に数えるので、呼ぶ側で出す。 + fn counted(&mut self, lines: usize, call: impl FnOnce(&mut Term)) { // 代替画面に履歴は無い。全画面の UI が流す行は数えない。 - if self.alt() { - call(self.inner); - return; + if !self.alt() { + self.hooked += lines as u64; } - let before = self.history(); call(self.inner); - let after = self.history(); - let diff = after.saturating_sub(before); - let counted = if after >= self.cap { - diff.max(guess) - } else { - diff - }; - self.pushed += counted as u64; } fn history(&self) -> usize { @@ -163,17 +181,6 @@ impl<'a> Counting<'a> { self.inner.grid().cursor.input_needs_wrap && self.folds() } - /// 折り返しの起きうる 1 文字を、数えながら書き込む。 - /// - /// 行末でしか通らないので、`input` の本体から外へ出しておく。 - /// 入れたままにすると、字を流す速さが 1 割ほど落ちる(`--throughput` で確かめた)。 - #[cold] - #[inline(never)] - fn wrapped_input(&mut self, c: char) { - let guess = usize::from(self.folds()); - self.counted(guess, |term| term.input(c)); - } - /// 画面に何か書いてある行数。ED 2 が履歴へ押し上げる行数になる。 fn filled_lines(&self) -> usize { let grid = self.inner.grid(); @@ -199,69 +206,54 @@ macro_rules! forward { } impl Handler for Counting<'_> { - fn input(&mut self, c: char) { - // 折り返すのは、行末に折り返しを溜めているときと、行末の 1 桁に入らない - // 全角文字が来たときだけである。1 文字ごとに通る道なので、それ以外は - // 何も読まずに通す。ASCII は必ず 1 桁なので、桁数も幅も見ない。 - let grid = self.inner.grid(); - let may_wrap = grid.cursor.input_needs_wrap - || (!c.is_ascii() - && grid.cursor.point.column.0 + 1 >= grid.columns() - && c.width().is_some_and(|w| w >= 2)); - if may_wrap { - self.wrapped_input(c); - return; - } - self.inner.input(c); - } - fn put_tab(&mut self, count: u16) { // 行末のタブは折り返しになる。 - let guess = usize::from(self.wrapping()); - self.counted(guess, |term| term.put_tab(count)); + let lines = usize::from(self.wrapping()); + self.counted(lines, |term| term.put_tab(count)); } fn linefeed(&mut self) { - let guess = usize::from(self.at_bottom()); - self.counted(guess, |term| term.linefeed()); + let lines = usize::from(self.at_bottom()); + self.counted(lines, |term| term.linefeed()); } fn newline(&mut self) { // NEL は改行と同じ道を通る。 - let guess = usize::from(self.at_bottom()); - self.counted(guess, |term| term.newline()); + let lines = usize::from(self.at_bottom()); + self.counted(lines, |term| term.newline()); } fn scroll_up(&mut self, lines: usize) { - let guess = lines.min(self.inner.grid().screen_lines()); - self.counted(guess, |term| term.scroll_up(lines)); + 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 guess = if grid.cursor.point.line.0 == 0 { + let pushed = if grid.cursor.point.line.0 == 0 { lines.min(grid.screen_lines()) } else { 0 }; - self.counted(guess, |term| term.delete_lines(lines)); + self.counted(pushed, |term| term.delete_lines(lines)); } fn clear_screen(&mut self, mode: ClearMode) { // ED 2 は本画面では消さずに、書いてあるぶんを履歴へ押し上げる。 - let guess = if matches!(mode, ClearMode::All) && !self.alt() { + let lines = if matches!(mode, ClearMode::All) && !self.alt() { self.filled_lines() } else { 0 }; - self.counted(guess, |term| term.clear_screen(mode)); + 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); @@ -357,9 +349,9 @@ mod tests { fn feed(cols: usize, lines: usize, scrollback: usize, script: &[u8]) -> (u64, usize) { let mut term = make_term(cols, lines, scrollback); let mut parser: Processor = Processor::new(); - let mut counting = Counting::new(&mut term, 0, scrollback); + let mut counting = Counting::new(&mut term, 0); parser.advance(&mut counting, script); - let pushed = counting.pushed(); + let pushed = counting.finish(); (pushed, term.grid().history_size()) } @@ -404,6 +396,7 @@ mod tests { fn 折り返した行も数える() { // 10 桁の画面を最下行まで埋めてから、35 文字を流す。 // 折り返しも改行と同じように 1 行ずつ押し出す。 + // 行ごとの道からは見えないが、上限より下なら履歴行数の差に出る。 let mut script = plain_lines(4); script.extend(b"\r\n"); script.extend([b'x'; 35]); @@ -434,24 +427,35 @@ mod tests { #[test] fn 上限に達してからも数え方は変わらない() { - // 同じ台本を、上限の広い端末と狭い端末へ流す。広いほうは履歴行数が - // 答え合わせになり、狭いほうは見積もりで数える。両者は一致する。 + // 同じ台本を、上限の広い端末と狭い端末へ流す。広いほうは履歴行数の差で + // 数え、狭いほうは行ごとの道で数える。両者は一致する。 let mut script = plain_lines(10); - script.extend(b"\r\n"); - script.extend([b'y'; 73]); // 折り返し 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(10, 4, 10_000, &script); + let (wide, history) = feed(20, 4, 10_000, &script); assert_eq!(wide, history as u64, "上限に届かなければ履歴行数と一致する"); - let (narrow, capped) = feed(10, 4, 3, &script); + let (narrow, capped) = feed(20, 4, 3, &script); assert_eq!(capped, 3, "狭いほうは上限で止まっている"); assert_eq!(narrow, wide, "上限に達しても同じ数になる"); } + #[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 通し番号から行位置を出す() { assert_eq!(serial(100, 0), 100); @@ -490,16 +494,31 @@ mod tests { assert_eq!(ticks.time_of(10), Some(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), Some(t0), "1 秒経っていないので積まない"); + ticks.push(30, t0 + Duration::from_secs(1)); + assert_eq!( + ticks.time_of(30), + Some(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_millis(i)); + ticks.push(i, t0 + Duration::from_secs(i)); } assert_eq!( ticks.time_of(0), - Some(t0 + Duration::from_millis(100)), + Some(t0 + Duration::from_secs(100)), "溢れた 100 件は捨て、残っている最古の時刻を返す" ); } diff --git a/src/pty.rs b/src/pty.rs index 69ce1d0..c48da6f 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -190,7 +190,6 @@ pub fn spawn(opts: SpawnOptions<'_>, ui_tx: UiSender) -> Result, /// 押し出した行数と時刻の対応。 ticks: Arc>, - /// スクロールバックの上限(行)。数え方の切り替えに使う。 - scrollback: usize, } fn spawn_reader( @@ -352,7 +349,6 @@ fn spawn_reader( activity, pushed, ticks, - scrollback, } = flags; thread::Builder::new() .name("termit-pty-read".into()) @@ -407,18 +403,18 @@ fn spawn_reader( let mut pos = 0usize; // OSC の終端ごとに区切って流し、その時点の画面を読む。 for (end, ev) in events { - let mut counting = Counting::new(&mut term, counted, scrollback); + let mut counting = Counting::new(&mut term, counted); parser.advance(&mut counting, &bytes[pos..end]); - counted = counting.pushed(); + 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)); } - let mut counting = Counting::new(&mut term, counted, scrollback); + let mut counting = Counting::new(&mut term, counted); parser.advance(&mut counting, &bytes[pos..]); - counted = counting.pushed(); + counted = counting.finish(); } // 行数と時刻は、錠を手放してから 1 読みにつき 1 回だけ残す。 pushed.store(counted, Ordering::Relaxed); diff --git a/src/throughput.rs b/src/throughput.rs index 8310eb1..9e9f461 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); + 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); + parser.advance(&mut counting, &chunk[pos..]); + counted = counting.finish(); parse += t.elapsed(); } drop(rx); From 35dfeb877d15759147c69ac1e434c1fdd5f5aebe Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 21:08:58 +0900 Subject: [PATCH 05/30] 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 --- src/lines.rs | 61 +++++++++++++++++++++++++++++++++-------------- src/pty.rs | 8 +++++-- src/throughput.rs | 4 ++-- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index bef8bb8..894ef6c 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -96,22 +96,22 @@ impl Ticks { /// `Processor::advance` は `Handler` を実装するものを何でも受け取るので、 /// `Term` の代わりにこれを渡せば、通った escape sequence を数えられる。 /// -/// 数え方は 2 つを合わせる。 +/// 数え方は 2 つあり、始めた時点で履歴が上限に達していたかで選ぶ。 /// -/// 1 つは履歴行数の差である。上限に届くまでは押し出した行が 1 行ずつ履歴を -/// 伸ばすので、始めと終わりの差がそのまま答えになる。どの道を通っても漏れない。 -/// もう 1 つは行ごとの道での数え上げで、改行・NEL・SU・行削除・画面消去(ED 2)・ -/// 行末のタブを数える。上限に達すると差が出なくなるので、こちらが答えになる。 -/// 多いほうを採れば、どちらの側にいても数が合う。 +/// 達していなければ、履歴行数の差がそのまま答えである。押し出した行が 1 行ずつ +/// 履歴を伸ばすので、どの道を通っても漏れない。履歴へ入らない送り(DECSTBM で +/// 範囲を絞った中のスクロール)を数えてしまうこともない。 +/// 達していれば差が出ないので、行ごとの道で数えたほうを答えにする。行ごとに +/// 数えるのは、改行・NEL・SU・行削除・画面消去(ED 2)・行末のタブである。 /// -/// 数え漏らしと数え過ぎが残る。 +/// 数え漏らしが残る。 /// /// - 行末の折り返しは `Term` が自分の `linefeed` を呼ぶので、こちらの `linefeed` /// を通らない。上限に届くまでは差に出るので数えられるが、上限に達してからは /// 数えられない。1 文字ごとに見れば数えられるが、それは `input` を 1 割ほど /// 遅くするので採らない(仕様 §4 の非目標)。 -/// - DECSTBM で画面の一部だけをスクロール範囲にしている本画面では、履歴へ入らない -/// 改行を数えてしまうことがある。 +/// - 上限にちょうど届く 1 回ぶんでは、届いたあとの押し出しを数えない。 +/// 取りこぼしはその 1 回ぶんで止まる。 /// - `Term::resize` の折り返し直しは行の位置を動かすので、大きさを変えると /// 古い通し番号は少しずれる。 pub struct Counting<'a> { @@ -119,12 +119,14 @@ pub struct Counting<'a> { pushed: u64, /// 始めた時点の履歴行数。代替画面にいたなら `None`。 before: Option, + /// スクロールバックの上限(行)。履歴行数の差が出ない側にいるかの判定に使う。 + cap: usize, /// 行ごとの道で数えた行数。 hooked: u64, } impl<'a> Counting<'a> { - pub fn new(inner: &'a mut Term, pushed: u64) -> Self { + 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()); @@ -132,18 +134,22 @@ impl<'a> Counting<'a> { inner, pushed, before, + cap, hooked: 0, } } /// 数え終えて、押し出した行の合計を返す。 pub fn finish(self) -> u64 { - let delta = match self.before { - // 途中で画面が入れ替わっていれば、差は本画面のものではない。 + let counted = match self.before { + // 上限に達していれば、もう履歴は伸びない。行ごとに数えたほうを採る。 + Some(before) if before >= self.cap => self.hooked, + // 伸びる側にいれば、差が正確である。 Some(before) if !self.alt() => self.history().saturating_sub(before) as u64, - _ => 0, + // 途中で画面が入れ替わっていれば、差は本画面のものではない。 + _ => self.hooked, }; - self.pushed + delta.max(self.hooked) + self.pushed + counted } /// 押し出しの起きうる呼び出しを、数えながら行う。 @@ -345,13 +351,18 @@ mod tests { /// 台本を端末へ流し、数えた行数と、端末が実際に抱えた履歴行数を返す。 /// - /// 上限に届くまでは履歴行数が答え合わせになる。 + /// 上限に届くまでは履歴行数が答え合わせになる。読み手と同じように、 + /// 1 回の読みごとに `Counting` を作り直す。区切りは改行に置き、 + /// 行単位で届く出力を真似る。 fn feed(cols: usize, lines: usize, scrollback: usize, script: &[u8]) -> (u64, usize) { let mut term = make_term(cols, lines, scrollback); let mut parser: Processor = Processor::new(); - let mut counting = Counting::new(&mut term, 0); - parser.advance(&mut counting, script); - let pushed = counting.finish(); + let mut pushed = 0u64; + for read in script.split_inclusive(|&b| b == b'\n') { + let mut counting = Counting::new(&mut term, pushed, scrollback); + parser.advance(&mut counting, read); + pushed = counting.finish(); + } (pushed, term.grid().history_size()) } @@ -443,6 +454,20 @@ mod tests { 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` の中で改行になるので、行ごとの道からは見えない。 diff --git a/src/pty.rs b/src/pty.rs index c48da6f..6dd34c7 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -190,6 +190,7 @@ pub fn spawn(opts: SpawnOptions<'_>, ui_tx: UiSender) -> Result, /// 押し出した行数と時刻の対応。 ticks: Arc>, + /// スクロールバックの上限(行)。行の数え方の判定に使う。 + scrollback: usize, } fn spawn_reader( @@ -349,6 +352,7 @@ fn spawn_reader( activity, pushed, ticks, + scrollback, } = flags; thread::Builder::new() .name("termit-pty-read".into()) @@ -403,7 +407,7 @@ fn spawn_reader( let mut pos = 0usize; // OSC の終端ごとに区切って流し、その時点の画面を読む。 for (end, ev) in events { - let mut counting = Counting::new(&mut term, counted); + let mut counting = Counting::new(&mut term, counted, scrollback); parser.advance(&mut counting, &bytes[pos..end]); counted = counting.finish(); pos = end; @@ -412,7 +416,7 @@ fn spawn_reader( } out.push(UiEvent::Osc(id, ev)); } - let mut counting = Counting::new(&mut term, counted); + let mut counting = Counting::new(&mut term, counted, scrollback); parser.advance(&mut counting, &bytes[pos..]); counted = counting.finish(); } diff --git a/src/throughput.rs b/src/throughput.rs index 9e9f461..ba9fa6c 100644 --- a/src/throughput.rs +++ b/src/throughput.rs @@ -90,12 +90,12 @@ fn feed(payload: &[u8]) -> (Duration, Duration) { let t = Instant::now(); let mut pos = 0usize; for (end, _ev) in events { - let mut counting = Counting::new(&mut term, counted); + let mut counting = Counting::new(&mut term, counted, SCROLLBACK); parser.advance(&mut counting, &chunk[pos..end]); counted = counting.finish(); pos = end; } - let mut counting = Counting::new(&mut term, counted); + let mut counting = Counting::new(&mut term, counted, SCROLLBACK); parser.advance(&mut counting, &chunk[pos..]); counted = counting.finish(); parse += t.elapsed(); From 9dd51a2706a87ad68be23e346bbfc685ef59664e Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 21:30:33 +0900 Subject: [PATCH 06/30] 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 --- src/lines.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++-------- src/pty.rs | 8 +++++- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index 894ef6c..985dd52 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -44,6 +44,9 @@ pub fn line_of(serial: i64, pushed: u64, history_size: usize) -> Option { } /// 覚えておく目盛りの数。1 秒に 1 つ積むので、約 68 分ぶんになる。 +/// +/// 1 件は `(u64, Instant)` の 24 バイトで、セッションあたり約 96KB になる +/// (`size_of` で確かめた)。 pub const TICKS: usize = 4096; /// 目盛りの間隔。仕様は 1 秒の分解能で足りるとしている。 @@ -104,14 +107,18 @@ impl Ticks { /// 達していれば差が出ないので、行ごとの道で数えたほうを答えにする。行ごとに /// 数えるのは、改行・NEL・SU・行削除・画面消去(ED 2)・行末のタブである。 /// -/// 数え漏らしが残る。 +/// 上限に達してからは、数え漏らしと数え過ぎが残る。行ごとの道は画面の形しか +/// 見ていないからである。 /// /// - 行末の折り返しは `Term` が自分の `linefeed` を呼ぶので、こちらの `linefeed` /// を通らない。上限に届くまでは差に出るので数えられるが、上限に達してからは /// 数えられない。1 文字ごとに見れば数えられるが、それは `input` を 1 割ほど /// 遅くするので採らない(仕様 §4 の非目標)。 -/// - 上限にちょうど届く 1 回ぶんでは、届いたあとの押し出しを数えない。 -/// 取りこぼしはその 1 回ぶんで止まる。 +/// - DECSTBM で送り範囲を絞った本画面では、履歴へ入らない送りを数えてしまう。 +/// `at_bottom` は画面の最下行を見るが `Term::linefeed` が見るのは +/// `scroll_region.end` で、履歴が伸びるのは `region.start == 0` のときだけである。 +/// `scroll_up` は画面の高さで、`delete_lines` はカーソルが 0 行目かどうかで、 +/// `folds` は範囲を見ずに数える。範囲は `Term` の外から読めないので追えない。 /// - `Term::resize` の折り返し直しは行の位置を動かすので、大きさを変えると /// 古い通し番号は少しずれる。 pub struct Counting<'a> { @@ -142,10 +149,20 @@ impl<'a> Counting<'a> { /// 数え終えて、押し出した行の合計を返す。 pub fn finish(self) -> u64 { let counted = match self.before { - // 上限に達していれば、もう履歴は伸びない。行ごとに数えたほうを採る。 + // すでに上限に達していれば、履歴は伸びない。行ごとに数えたほうを採る。 Some(before) if before >= self.cap => self.hooked, - // 伸びる側にいれば、差が正確である。 - Some(before) if !self.alt() => self.history().saturating_sub(before) as u64, + Some(before) if !self.alt() => { + let after = self.history(); + let delta = after.saturating_sub(before) as u64; + if after >= self.cap { + // この 1 回で上限に届いた。届いたあとの押し出しは差に出ない。 + // 1 回の読みは 64KB まで入るので、改行だけなら何千行にもなる。 + delta.max(self.hooked) + } else { + // 伸びる側にいるあいだは、差が正確である。 + delta + } + } // 途中で画面が入れ替わっていれば、差は本画面のものではない。 _ => self.hooked, }; @@ -349,16 +366,27 @@ mod tests { crate::term::new_term(size, scrollback, proxy) } - /// 台本を端末へ流し、数えた行数と、端末が実際に抱えた履歴行数を返す。 + /// 台本を 1 回の読みとして流し、数えた行数と、端末が抱えた履歴行数を返す。 /// - /// 上限に届くまでは履歴行数が答え合わせになる。読み手と同じように、 - /// 1 回の読みごとに `Counting` を作り直す。区切りは改行に置き、 - /// 行単位で届く出力を真似る。 + /// 読み手は 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 script.split_inclusive(|&b| b == b'\n') { + for read in reads { let mut counting = Counting::new(&mut term, pushed, scrollback); parser.advance(&mut counting, read); pushed = counting.finish(); @@ -481,6 +509,21 @@ mod tests { 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); @@ -510,6 +553,19 @@ mod tests { assert_eq!(Ticks::new().time_of(1), None, "目盛りが無ければ時刻も無い"); } + #[test] + fn 間の空いた出力は前の出力の時刻にならない() { + // 1 つめの出力で 76 行押し出し、10 分おいて 2 つめの出力で 100 行押し出した。 + // 目盛りに入れるのは読む前の数なので、2 つめで出た行は 2 つめの時刻になる。 + 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), Some(t1), "2 つめの出力で出た行"); + assert_eq!(ticks.time_of(30), Some(t0), "1 つめの出力で出た行"); + } + #[test] fn 同じ行数の目盛りは積まない() { let t0 = Instant::now(); diff --git a/src/pty.rs b/src/pty.rs index 6dd34c7..ea9b9d7 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -402,6 +402,8 @@ fn spawn_reader( let bytes = &buf[..n]; let events = scanner.feed(bytes); let mut out: Vec = Vec::new(); + // この読みで出る行は、これより後の番号を持つ。目盛りに入れる数である。 + let before = counted; { let mut term = term.lock(); let mut pos = 0usize; @@ -421,11 +423,15 @@ fn spawn_reader( counted = counting.finish(); } // 行数と時刻は、錠を手放してから 1 読みにつき 1 回だけ残す。 + // + // 目盛りへ入れるのは読む前の数である。読んだ後の数を入れると、 + // この読みで出た行の番号がどれも目盛りより大きくなり、一つ前の + // 目盛り(間が空いていれば何分も前の時刻)に落ちてしまう。 pushed.store(counted, Ordering::Relaxed); ticks .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(counted, std::time::Instant::now()); + .push(before, std::time::Instant::now()); for ev in out { if ui_tx.send_event(ev).is_err() { return; From 7db52eac4d740e55d8bc451f5bf620826e091e14 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 21:35:48 +0900 Subject: [PATCH 07/30] 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 --- src/lines.rs | 40 ++++++++++++++++++++++++++++++++++++++-- src/pty.rs | 3 ++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index 985dd52..5e6d8cc 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -43,7 +43,11 @@ pub fn line_of(serial: i64, pushed: u64, history_size: usize) -> Option { i32::try_from(line).ok() } -/// 覚えておく目盛りの数。1 秒に 1 つ積むので、約 68 分ぶんになる。 +/// 覚えておく目盛りの数。 +/// +/// 積むのは行を押し出した読みだけで、それも 1 秒に 1 つまでなので、 +/// 68 分以上ぶんになる。プロンプトの描き直しのような押し出さない読みは +/// 枠を使わないので、静かな端末ではもっと長く覚えている。 /// /// 1 件は `(u64, Instant)` の 24 バイトで、セッションあたり約 96KB になる /// (`size_of` で確かめた)。 @@ -66,10 +70,26 @@ impl Ticks { Self::default() } + /// 1 回の読みを目盛りに残す。`before` は読む前、`after` は読んだ後の行数。 + /// + /// 目盛り `(p, t)` は「番号 p から次の目盛りまでの行が t に出た」という意味なので、 + /// 1 行も押し出していない読みには残すものが無い。それどころか、同じ行数の目盛りは + /// 積まないので、次に本当に押し出した読みの枠を取ってしまう。プロンプトの + /// 描き直しや打った字の echo がまさにそれで、`ls` の出力がその前の出力の時刻に + /// なってしまう。 + pub fn mark_read(&mut self, before: u64, after: u64, at: Instant) { + if after == before { + return; + } + self.push(before, at); + } + /// いまの行数と時刻を積む。 /// /// 行数が増えていないか、前の目盛りから 1 秒経っていなければ何もしない。 - pub fn push(&mut self, pushed: u64, at: Instant) { + /// 読み手は押し出した読みでしか呼ばないので、行数は必ず増えている。 + /// それでも見るのは、並びが昇順であることが `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; @@ -566,6 +586,22 @@ mod tests { assert_eq!(ticks.time_of(30), Some(t0), "1 つめの出力で出た行"); } + #[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, t0); + ticks.mark_read(76, 76, prompt); + ticks.mark_read(76, 176, t1); + assert_eq!(ticks.time_of(150), Some(t1), "`ls` の出力で出た行"); + assert_eq!(ticks.time_of(30), Some(t0), "1 つめの出力で出た行"); + } + #[test] fn 同じ行数の目盛りは積まない() { let t0 = Instant::now(); diff --git a/src/pty.rs b/src/pty.rs index ea9b9d7..3a6288c 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -427,11 +427,12 @@ fn spawn_reader( // 目盛りへ入れるのは読む前の数である。読んだ後の数を入れると、 // この読みで出た行の番号がどれも目盛りより大きくなり、一つ前の // 目盛り(間が空いていれば何分も前の時刻)に落ちてしまう。 + // 1 行も押し出していない読みを渡すかどうかは `Ticks` が決める。 pushed.store(counted, Ordering::Relaxed); ticks .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(before, std::time::Instant::now()); + .mark_read(before, counted, std::time::Instant::now()); for ev in out { if ui_tx.send_event(ev).is_err() { return; From c1a70db23b947401b71ef68aff3bf2ecddc0642a Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 21:39:45 +0900 Subject: [PATCH 08/30] feat: match a line case-insensitively and keep the hit centered Co-Authored-By: Claude Opus 5 --- src/main.rs | 1 + src/render.rs | 92 ++++++++++++++++++++++++++++++++++++++++++- src/scan.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/scan.rs diff --git a/src/main.rs b/src/main.rs index 9946257..cb33421 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod probe; mod pty; mod rect; mod render; +mod scan; mod search; mod secret; mod session; diff --git a/src/render.rs b/src/render.rs index 1d9f0b2..26d4642 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,52 @@ impl Renderer { } } + /// 幅に収まるよう、当たりを残して前後から落とす。落とした側に省略記号を付ける。 + /// + /// `hit` は `text` の**バイト**位置。返す範囲は、返す文字列の中での当たりの + /// **バイト**位置で、先頭の省略記号のぶんだけずれる。当たりが残せる幅より + /// 長ければ、収まる分だけに切って返す。 + // 呼ぶのは結果の行を描く仕事。それまで読み手がいない。 + #[allow(dead_code)] + 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 +1161,35 @@ fn build(chars: &[char], keep: usize, drop_tail: bool) -> String { } } +/// 当たりを中心に `keep` 字を残し、落とした側に省略記号を付ける。 +/// +/// `hit_chars` は文字の添字、`hit` は `text` のバイト位置。戻りの範囲は +/// 組み立てた文字列のバイト位置で、残した窓に収まる分に切ってある。 +#[allow(dead_code)] +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 +1234,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 +1288,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..eb805d2 --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,105 @@ +//! 1 行の中の当たりと、当たりを中心にした詰め方。 +//! +//! 左ペインは 200pt ほどの幅しかなく、結果の 1 行には 20 字前後しか載らない。 +//! 末尾を落とす `fit_tail` も先頭を落とす `fit_head` も、探した語そのものを +//! 落としかねない。そこで当たりを残して、前後から落とす。 +//! +//! 単位に注意する。`find_hit` が返すのは元の文字列の**バイト**位置、 +//! `window_around` が受け取り返すのは**文字**の添字である。 + +use std::ops::Range; + +/// 大文字小文字を無視した素の一致。`needle_lower` は呼ぶ側が小文字にして渡す。 +/// +/// 返す範囲は `text` の**バイト**位置。小文字にすると長さが変わる字('İ' など)が +/// あるので、小文字側のバイト位置から元のバイト位置へ戻す対応表を作る。 +// 呼ぶのは次の仕事(全セッションの走査)。それまで読み手がいない。 +#[allow(dead_code)] +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 つの真偽は、頭を落としたか・尾を落としたか。 +#[allow(dead_code)] +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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + } +} From cfefdf443ffe4d76f8d9ddb860da28b21f3be92f Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 22:01:56 +0900 Subject: [PATCH 09/30] 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 --- src/scan.rs | 498 +++++++++++++++++++++++++++++++++++++++++++++++++- src/secret.rs | 9 +- 2 files changed, 500 insertions(+), 7 deletions(-) diff --git a/src/scan.rs b/src/scan.rs index eb805d2..b1b62b4 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,4 +1,6 @@ -//! 1 行の中の当たりと、当たりを中心にした詰め方。 +//! 全セッションのスクロールバックを走り、当たりを新しい順に並べる。 +//! +//! 1 行の中の当たりと、当たりを中心にした詰め方もここに置く。 //! //! 左ペインは 200pt ほどの幅しかなく、結果の 1 行には 20 字前後しか載らない。 //! 末尾を落とす `fit_tail` も先頭を落とす `fit_head` も、探した語そのものを @@ -7,14 +9,27 @@ //! 単位に注意する。`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; + +use crate::lines::{self, Ticks}; +use crate::secret::Redactor; +use crate::session::SessionId; +use crate::term::EventProxy; /// 大文字小文字を無視した素の一致。`needle_lower` は呼ぶ側が小文字にして渡す。 /// /// 返す範囲は `text` の**バイト**位置。小文字にすると長さが変わる字('İ' など)が /// あるので、小文字側のバイト位置から元のバイト位置へ戻す対応表を作る。 -// 呼ぶのは次の仕事(全セッションの走査)。それまで読み手がいない。 -#[allow(dead_code)] pub fn find_hit(text: &str, needle_lower: &str) -> Option> { if needle_lower.is_empty() { return None; @@ -57,9 +72,486 @@ pub fn window_around(chars: &[char], hit: Range, keep: usize) -> (Range 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)] +// 読むのは結果を描く仕事と、選んで飛ぶ仕事。それまで読み手がいない。 +#[allow(dead_code)] +pub struct Hit { + pub id: SessionId, + pub kind: HitKind, + /// 当たった行の通し番号。名前・題名・枝なら `None`。 + pub serial: Option, + /// その行が現れた時刻。目盛りが残っていなければ `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, + } + } +} + +/// 全セッションのスクロールバックを、新しい方から探す。 +/// +/// 走るのは別スレッドである(仕様 §10)。1 フレームの余白は 0.3ms ほどしかなく、 +/// 10000 行の走査はそこに入らない。 +// 呼ぶのは次の仕事(別スレッドで走らせ、結果を UI へ渡す)。それまで読み手がいない。 +#[allow(dead_code)] +pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits) -> Vec { + // 針も 1 文字ずつ小文字にする。`find_hit` は干し草を `char::to_lowercase` で + // 小文字にするので、こちらだけ `str::to_lowercase` を使うと食い違う。 + // ギリシャ語の語末シグマがそれで、`str` は語末の 'Σ' を 'ς' にするが + // `char` は必ず 'σ' にする。打った語が黙って当たらなくなる。 + let needle: String = query.chars().flat_map(char::to_lowercase).collect(); + 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(|a, b| match (a.at, b.at) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(a), Some(b)) => b.cmp(&a), + }); + hits.truncate(limits.total); + hits +} + +/// 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<(i64, String, Range)> = Vec::new(); + // この塊の先にまだ遡る行があるか。 + let more; + { + let term = target.term.lock(); + let grid = term.grid(); + // 代替画面の履歴行数は 0 である。`vim` や `less` が開いているあいだは + // いま出ている画面しか探せない。穴として受け入れる(仕様 §7)。 + 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; + + text.clear(); + for cell in &grid[Line(row)][..] { + // 全角の後ろ半分は実体を持たないコマなので飛ばす + // (`session.rs` の `grid_text` と同じ規則)。 + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + text.push(cell.c); + } + while text.ends_with(' ') { + text.pop(); + } + 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((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: Some(serial), + at: ticks.time_of(serial), + text, + hit, + }); + } + } + if !more { + return; + } + } +} + +/// 伏せ字を通してから当たりを探す。返すのは伏せたあとの文字列と、その中の位置。 +/// +/// 先に素の文字列で見るのは速さのためである。`Redactor` は式を 10 本ほど走らせる。 +/// 14 本 × 10000 行の全部に掛ければ秒の単位になるので、当たらない行は式を +/// 1 本も走らせずに捨てる。 +/// +/// 当たった行だけ伏せ字を通し、伏せたあとの文字列でもう一度探す。鍵の中にしか +/// ない当たりはここで消える。端末で `••••` に見えている鍵が、結果の一覧に +/// 素のまま出ることはない(仕様 §6)。 +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; + + 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 つなら + /// どの行もその時刻になるので、本どうしの新しさを比べるのに足りる。 + /// 押し出した行数は走査では読むだけなので、試験では 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, 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 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 件で切る。 + 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 t = target(1, &script, None); + 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:?}"); + } #[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()); From da858b6b23abaaf9bcb82b6dd0488bfefa09a934 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 22:25:11 +0900 Subject: [PATCH 10/30] 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 --- src/lines.rs | 83 +++++++++++++++++++++++++++++++++++------- src/scan.rs | 101 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 146 insertions(+), 38 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index 5e6d8cc..bf2e897 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -8,8 +8,8 @@ //! 行数 `pushed` を足せば、その行に一度しか付かない番号になる(`serial`)。 //! 逆に、覚えておいた番号からいまの行位置も出せる(`line_of`)。 //! -//! 番号を引く側(`serial`・`line_of`・`Ticks::time_of`)の読み手は左ペインの検索で、 -//! まだ入っていない。数える側だけを先に置いている。 +//! 番号を引く側(`serial`・`line_of`・`Ticks::time_of`)の読み手は、左ペインの +//! 全セッション検索(`scan.rs`)である。 use std::collections::VecDeque; use std::time::{Duration, Instant}; @@ -28,13 +28,11 @@ use alacritty_terminal::Term; use crate::term::EventProxy; /// 行位置 `line` の通し番号。画面の最上段がちょうど `pushed` になる。 -#[allow(dead_code)] pub fn serial(pushed: u64, line: i32) -> i64 { pushed as i64 + i64::from(line) } /// 通し番号 `serial` がいまどの行位置にあるか。履歴から押し出されていれば `None`。 -#[allow(dead_code)] pub fn line_of(serial: i64, pushed: u64, history_size: usize) -> Option { let line = serial - pushed as i64; if line < -(history_size as i64) { @@ -59,15 +57,43 @@ pub const TICKS: usize = 4096; const TICK_GAP: Duration = Duration::from_secs(1); /// 通し番号と時刻の対応。古いものから捨てる環である。 -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Ticks { /// `(そのときまでに押し出した行数, 時刻)`。行数の昇順に並ぶ。 marks: VecDeque<(u64, Instant)>, } impl Ticks { + /// 目盛りを 1 つ入れた環を作る。 + /// + /// 種を入れるのは、一度も行を押し出していない本(開いたばかり・静かな本・ + /// 流れる前に代替画面へ入った本)の行にも時刻を付けるためである。空の環では + /// `time_of` が `None` を返し、その行が全セッション検索で時刻の無い件になって + /// しまう。種の時刻は本を開いた時刻なので、静かな古い行を本当に新しい行より + /// 上に出すことはない。 + /// + /// 代わりに、最初に**押し出した**読みの目盛りが `push` の「同じ行数は積まない」で + /// 落ちる。本を開いた数ミリ秒後に来るのはプロンプトの読みで、あれは 1 行も + /// 押し出さないので目盛りを取らない。最初に押し出すのは画面より長い出力を出した + /// 読みで、それは何分も後かもしれない。その読みで出た行は、本当の時刻ではなく + /// 本を開いた時刻になる。それ以降の読みは正しく日付が付く。 + /// 古く見えるぶんには倒れるので、静かな古い行が本当に新しい行より上へ出ることはない。 pub fn new() -> Self { - Self::default() + Self { + marks: VecDeque::from([(0, Instant::now())]), + } + } + + /// 目盛りを 1 つも持たない環。 + /// + /// 環そのものの決まり(積む・捨てる・引く)を確かめる試験のためにある。 + /// `new` の種が入っていると、試験が置いた目盛りが「同じ行数は積まない」や + /// 「1 秒おきに積む」で落ちてしまう。種の入らない道はここだけにしてある。 + #[cfg(test)] + pub(crate) fn empty() -> Self { + Self { + marks: VecDeque::new(), + } } /// 1 回の読みを目盛りに残す。`before` は読む前、`after` は読んだ後の行数。 @@ -105,13 +131,27 @@ impl Ticks { /// /// 目盛りを捨てたぶんの時刻は分からない。残っている最古の時刻を返す。 /// その行がそれより古いことは確かである。 - #[allow(dead_code)] pub fn time_of(&self, serial: i64) -> Option { let after = self .marks .partition_point(|&(pushed, _)| (pushed as i64) <= serial); self.marks.get(after.saturating_sub(1)).map(|&(_, at)| at) } + + /// いちばん新しい目盛りの時刻。 + /// + /// 代替画面の行は通し番号が引けない(`pushed` が進まないので、付けた番号は + /// 本画面の無関係な行を指す)。その行には、この本が最後に行を押し出した + /// 時刻を代わりに付ける。 + pub fn newest(&self) -> Option { + self.marks.back().map(|&(_, at)| at) + } +} + +impl Default for Ticks { + fn default() -> Self { + Self::new() + } } /// 履歴へ押し出した行を数えながら `Term` へ中継する。 @@ -559,7 +599,7 @@ mod tests { let t0 = Instant::now(); let t1 = t0 + Duration::from_secs(1); let t2 = t0 + Duration::from_secs(2); - let mut ticks = Ticks::new(); + let mut ticks = Ticks::empty(); ticks.push(10, t0); ticks.push(20, t1); ticks.push(30, t2); @@ -570,7 +610,22 @@ mod tests { Some(t0), "一番古い目盛りより前なら、残っている最古の時刻を返す" ); - assert_eq!(Ticks::new().time_of(1), None, "目盛りが無ければ時刻も無い"); + assert_eq!( + Ticks::empty().time_of(1), + None, + "目盛りが無ければ時刻も無い" + ); + } + + #[test] + fn 作った時点の時刻を種にする() { + // 一度も行を押し出していない本でも、行から時刻が引けること。 + // 引けないと、その本の行が全セッション検索で時刻の無い件になってしまう。 + let ticks = Ticks::new(); + assert!(ticks.time_of(0).is_some(), "画面の行にも時刻が付く"); + assert!(ticks.time_of(-5).is_some(), "履歴側の行にも時刻が付く"); + assert_eq!(ticks.newest(), ticks.time_of(0), "目盛りは 1 つだけ"); + assert_eq!(Ticks::empty().newest(), None); } #[test] @@ -579,7 +634,7 @@ mod tests { // 目盛りに入れるのは読む前の数なので、2 つめで出た行は 2 つめの時刻になる。 let t0 = Instant::now(); let t1 = t0 + Duration::from_secs(600); - let mut ticks = Ticks::new(); + let mut ticks = Ticks::empty(); ticks.push(0, t0); ticks.push(76, t1); assert_eq!(ticks.time_of(150), Some(t1), "2 つめの出力で出た行"); @@ -594,7 +649,7 @@ mod tests { let t0 = Instant::now(); let prompt = t0 + Duration::from_secs(1); let t1 = t0 + Duration::from_secs(600); - let mut ticks = Ticks::new(); + let mut ticks = Ticks::empty(); ticks.mark_read(0, 76, t0); ticks.mark_read(76, 76, prompt); ticks.mark_read(76, 176, t1); @@ -605,7 +660,7 @@ mod tests { #[test] fn 同じ行数の目盛りは積まない() { let t0 = Instant::now(); - let mut ticks = Ticks::new(); + let mut ticks = Ticks::empty(); ticks.push(10, t0); ticks.push(10, t0 + Duration::from_secs(1)); assert_eq!(ticks.time_of(10), Some(t0), "先に見た時刻を残す"); @@ -614,7 +669,7 @@ mod tests { #[test] fn 目盛りは一秒おきに積む() { let t0 = Instant::now(); - let mut ticks = Ticks::new(); + let mut ticks = Ticks::empty(); ticks.push(10, t0); ticks.push(20, t0 + Duration::from_millis(500)); assert_eq!(ticks.time_of(20), Some(t0), "1 秒経っていないので積まない"); @@ -629,7 +684,7 @@ mod tests { #[test] fn 目盛りは古いものから捨てる() { let t0 = Instant::now(); - let mut ticks = Ticks::new(); + let mut ticks = Ticks::empty(); for i in 0..(TICKS as u64 + 100) { ticks.push(i, t0 + Duration::from_secs(i)); } diff --git a/src/scan.rs b/src/scan.rs index b1b62b4..159cc15 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -9,7 +9,6 @@ //! 単位に注意する。`find_hit` が返すのは元の文字列の**バイト**位置、 //! `window_around` が受け取り返すのは**文字**の添字である。 -use std::cmp::Ordering; use std::ops::Range; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; use std::sync::{Arc, Mutex}; @@ -19,6 +18,7 @@ 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}; @@ -104,7 +104,7 @@ pub enum HitKind { pub struct Hit { pub id: SessionId, pub kind: HitKind, - /// 当たった行の通し番号。名前・題名・枝なら `None`。 + /// 当たった行の通し番号。名前・題名・枝と、代替画面の行なら `None`。 pub serial: Option, /// その行が現れた時刻。目盛りが残っていなければ `None`。 pub at: Option, @@ -154,13 +154,15 @@ pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits for target in targets { scan_one(target, &needle, redactor, limits, &mut hits); } - // 新しい順に並べる。時刻の無い件(名前・題名・枝)は先頭へ置く。 - // 安定な並べ替えなので、時刻が同じあいだは走った順=本ごとの新しい順が残る。 - hits.sort_by(|a, b| match (a.at, b.at) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Less, - (Some(_), None) => Ordering::Greater, - (Some(a), Some(b)) => b.cmp(&a), + // 新しい順に並べる。先頭に置くのは名前・題名・枝で、時刻が無いからではなく + // 行ではないからである。行の時刻は引けないこともある(目盛りの無い本、 + // 目盛りを 1 つも持たない代替画面の本)。その行は後ろへ回す。 + // 分からない行を、分かっている新しい行の上に出さない。 + // 安定な並べ替えなので、同じ時刻のあいだは走った順=本ごとの新しい順が残る。 + hits.sort_by(|a, b| { + let pinned = |h: &Hit| h.kind != HitKind::Line; + // 時刻は `None` が小さいので、新しい順に並べれば後ろへ回る。 + pinned(b).cmp(&pinned(a)).then_with(|| b.at.cmp(&a.at)) }); hits.truncate(limits.total); hits @@ -227,14 +229,18 @@ fn scan_lines( return; } // この塊で当たったぶん。時刻は錠を放してから付ける。 - let mut found: Vec<(i64, String, Range)> = Vec::new(); + 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(); - // 代替画面の履歴行数は 0 である。`vim` や `less` が開いているあいだは - // いま出ている画面しか探せない。穴として受け入れる(仕様 §7)。 let history = grid.history_size(); let top = grid.screen_lines() as i32 - 1; // 行位置に足して通し番号にする数。グリッドと同じ錠の下で読む。 @@ -288,7 +294,7 @@ fn scan_lines( continue; } last = Some(shown.clone()); - found.push((row_serial, shown, hit)); + found.push(((!alt).then_some(row_serial), shown, hit)); taken += 1; } next = Some(at); @@ -304,8 +310,12 @@ fn scan_lines( out.push(Hit { id: target.id, kind: HitKind::Line, - serial: Some(serial), - at: ticks.time_of(serial), + serial, + // 番号の引けない行には、この本が最後に押し出した時刻を付ける。 + at: match serial { + Some(serial) => ticks.time_of(serial), + None => ticks.newest(), + }, text, hit, }); @@ -361,19 +371,13 @@ mod tests { crate::term::new_term(size, scrollback, proxy) } - /// 台本を流した `Target` を 1 本作る。 + /// 台本を流した `Target` を 1 本作る。目盛りは呼ぶ側が渡す。 /// - /// `at` を渡すと、その時刻の目盛りを 1 つだけ残す。目盛りが 1 つなら - /// どの行もその時刻になるので、本どうしの新しさを比べるのに足りる。 /// 押し出した行数は走査では読むだけなので、試験では 0 のままにする。 - fn target(id: SessionId, play: &[u8], at: Option) -> Target { + fn target_with(id: SessionId, play: &[u8], ticks: Ticks) -> 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, at); - } Target { id, term: Arc::new(FairMutex::new(term)), @@ -385,6 +389,21 @@ mod tests { } } + /// 台本を流した `Target`。 + /// + /// `at` を渡すとその時刻の目盛りを 1 つだけ置く。目盛りが 1 つならどの行も + /// その時刻になるので、本どうしの新しさを比べるのに足りる。`None` なら + /// 目盛りの無い本になり、行から時刻が引けない。 + /// 環は `Ticks::empty` から作る。`Ticks::new` の種が入っていると、 + /// 置いた目盛りが「同じ行数は積まない」で落ちてしまう。 + fn target(id: SessionId, play: &[u8], at: Option) -> Target { + let mut ticks = Ticks::empty(); + if let Some(at) = at { + ticks.mark_read(0, 1, at); + } + target_with(id, play, ticks) + } + /// 伏せる式を 1 つも持たない `Redactor`。行の走りだけを見たいとき。 fn plain() -> Redactor { Redactor::new(&[]).expect("式が無ければ組み立てられる") @@ -422,6 +441,32 @@ mod tests { assert_eq!(hits[1].id, 1); } + #[test] + fn 時刻の無い行は後ろへ回す() { + // 時刻の引けない行を、分かっている新しい行の上に出さない。 + // 先頭に来るのは名前・題名・枝だけである。 + let t0 = Instant::now(); + let dated = target(1, &play(&["ses 時刻あり"]), Some(t0)); + let mut undated = target(2, &play(&["ses 時刻なし"]), None); + undated.name = "ses の本".to_string(); + let hits = scan(&[dated, undated], "ses", &plain(), Limits::default()); + assert_eq!(hits.len(), 3, "{hits:?}"); + assert_eq!(hits[0].kind, HitKind::Name, "名前は先頭に残る"); + assert_eq!(hits[1].at, Some(t0), "時刻の分かる行が先"); + assert_eq!(hits[2].at, None, "{hits:?}"); + } + + #[test] + fn 一度も流れていない本の行にも時刻が付く() { + // 開いたばかりの本は 1 行も押し出していないが、`Ticks::new` の種で + // 行から時刻が引ける。引けないと一覧の並びが決まらない。 + let t = target_with(1, &play(&["ses one"]), Ticks::new()); + 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); @@ -540,7 +585,11 @@ mod tests { 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 t = target(1, &script, None); + 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, newest); assert!(t.term.lock().mode().contains(TermMode::ALT_SCREEN)); assert_eq!( t.term.lock().grid().history_size(), @@ -551,6 +600,10 @@ mod tests { 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] From ce00013123589020925edc77aab8ecd5c7eb1f48 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 22:35:22 +0900 Subject: [PATCH 11/30] 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 --- src/lines.rs | 93 ++++++++++++++++++++++++---------------------- src/scan.rs | 103 +++++++++++++++++++++++++++++++++------------------ 2 files changed, 114 insertions(+), 82 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index bf2e897..64151be 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -61,38 +61,29 @@ const TICK_GAP: Duration = Duration::from_secs(1); pub struct Ticks { /// `(そのときまでに押し出した行数, 時刻)`。行数の昇順に並ぶ。 marks: VecDeque<(u64, Instant)>, + /// この本を開いた時刻。目盛りが 1 つも無いあいだの答えになる。 + /// + /// 一度も行を押し出していない本(開いたばかり・静かな本・流れる前に代替画面へ + /// 入った本)でも、行から時刻が引けるようにする。引けないと、その行は + /// 全セッション検索で時刻の無い件になってしまう。 + /// + /// これを目盛りとして**積んではいけない**。積むと、最初に押し出した読みの目盛りが + /// 「同じ行数は積まない」で落ちて、その読みで出た行が本を開いた時刻になる。 + /// 20 分プロンプトで待ってから打った命令の出力が 20 分前の時刻になり、 + /// ただ喋り続けていた本より下に沈む。探している行はたいていそれである。 + created_at: Instant, } impl Ticks { - /// 目盛りを 1 つ入れた環を作る。 + /// 目盛りを 1 つも持たない環を作る。作った時刻だけを覚えておく。 /// - /// 種を入れるのは、一度も行を押し出していない本(開いたばかり・静かな本・ - /// 流れる前に代替画面へ入った本)の行にも時刻を付けるためである。空の環では - /// `time_of` が `None` を返し、その行が全セッション検索で時刻の無い件になって - /// しまう。種の時刻は本を開いた時刻なので、静かな古い行を本当に新しい行より - /// 上に出すことはない。 - /// - /// 代わりに、最初に**押し出した**読みの目盛りが `push` の「同じ行数は積まない」で - /// 落ちる。本を開いた数ミリ秒後に来るのはプロンプトの読みで、あれは 1 行も - /// 押し出さないので目盛りを取らない。最初に押し出すのは画面より長い出力を出した - /// 読みで、それは何分も後かもしれない。その読みで出た行は、本当の時刻ではなく - /// 本を開いた時刻になる。それ以降の読みは正しく日付が付く。 - /// 古く見えるぶんには倒れるので、静かな古い行が本当に新しい行より上へ出ることはない。 + /// 目盛りは行を押し出した読みが積む。環が空のあいだ `time_of` と `newest` は + /// 作った時刻を返すので、一度も押し出していない本の行にも時刻が付く。 + /// 最初に押し出した読みの目盛りは、環が空なので必ず積まれる。 pub fn new() -> Self { - Self { - marks: VecDeque::from([(0, Instant::now())]), - } - } - - /// 目盛りを 1 つも持たない環。 - /// - /// 環そのものの決まり(積む・捨てる・引く)を確かめる試験のためにある。 - /// `new` の種が入っていると、試験が置いた目盛りが「同じ行数は積まない」や - /// 「1 秒おきに積む」で落ちてしまう。種の入らない道はここだけにしてある。 - #[cfg(test)] - pub(crate) fn empty() -> Self { Self { marks: VecDeque::new(), + created_at: Instant::now(), } } @@ -131,20 +122,25 @@ impl Ticks { /// /// 目盛りを捨てたぶんの時刻は分からない。残っている最古の時刻を返す。 /// その行がそれより古いことは確かである。 + /// 目盛りが 1 つも無ければ、本を開いた時刻を返す。 pub fn time_of(&self, serial: i64) -> Option { let after = self .marks .partition_point(|&(pushed, _)| (pushed as i64) <= serial); - self.marks.get(after.saturating_sub(1)).map(|&(_, at)| at) + Some( + self.marks + .get(after.saturating_sub(1)) + .map_or(self.created_at, |&(_, at)| at), + ) } /// いちばん新しい目盛りの時刻。 /// /// 代替画面の行は通し番号が引けない(`pushed` が進まないので、付けた番号は /// 本画面の無関係な行を指す)。その行には、この本が最後に行を押し出した - /// 時刻を代わりに付ける。 + /// 時刻を代わりに付ける。1 行も押し出していなければ、本を開いた時刻になる。 pub fn newest(&self) -> Option { - self.marks.back().map(|&(_, at)| at) + Some(self.marks.back().map_or(self.created_at, |&(_, at)| at)) } } @@ -599,7 +595,7 @@ mod tests { let t0 = Instant::now(); let t1 = t0 + Duration::from_secs(1); let t2 = t0 + Duration::from_secs(2); - let mut ticks = Ticks::empty(); + let mut ticks = Ticks::new(); ticks.push(10, t0); ticks.push(20, t1); ticks.push(30, t2); @@ -610,22 +606,29 @@ mod tests { Some(t0), "一番古い目盛りより前なら、残っている最古の時刻を返す" ); - assert_eq!( - Ticks::empty().time_of(1), - None, - "目盛りが無ければ時刻も無い" - ); } #[test] - fn 作った時点の時刻を種にする() { + fn 目盛りが無ければ作った時刻を返す() { // 一度も行を押し出していない本でも、行から時刻が引けること。 - // 引けないと、その本の行が全セッション検索で時刻の無い件になってしまう。 + let before = Instant::now(); let ticks = Ticks::new(); - assert!(ticks.time_of(0).is_some(), "画面の行にも時刻が付く"); - assert!(ticks.time_of(-5).is_some(), "履歴側の行にも時刻が付く"); - assert_eq!(ticks.newest(), ticks.time_of(0), "目盛りは 1 つだけ"); - assert_eq!(Ticks::empty().newest(), None); + let after = Instant::now(); + let at = ticks.time_of(0).expect("作った時刻を返す"); + assert!(at >= before && at <= after, "本を開いた時刻である"); + assert_eq!(ticks.time_of(-5), Some(at), "履歴側の行にも同じ時刻が付く"); + assert_eq!(ticks.newest(), Some(at), "最後の目盛りも作った時刻になる"); + } + + #[test] + fn 最初に押し出した読みの目盛りが残る() { + // 20 分プロンプトで待ってから打った命令の出力は、その読みの時刻になること。 + // 作った時刻を目盛りとして積むと、この読みの目盛りが「同じ行数は積まない」で + // 落ちて、出力が 20 分前の時刻になってしまう。探している行はたいていそれである。 + let mut ticks = Ticks::new(); + let first = Instant::now() + Duration::from_secs(1200); + ticks.mark_read(0, 76, first); + assert_eq!(ticks.time_of(30), Some(first), "出力はその読みの時刻になる"); } #[test] @@ -634,7 +637,7 @@ mod tests { // 目盛りに入れるのは読む前の数なので、2 つめで出た行は 2 つめの時刻になる。 let t0 = Instant::now(); let t1 = t0 + Duration::from_secs(600); - let mut ticks = Ticks::empty(); + let mut ticks = Ticks::new(); ticks.push(0, t0); ticks.push(76, t1); assert_eq!(ticks.time_of(150), Some(t1), "2 つめの出力で出た行"); @@ -649,7 +652,7 @@ mod tests { let t0 = Instant::now(); let prompt = t0 + Duration::from_secs(1); let t1 = t0 + Duration::from_secs(600); - let mut ticks = Ticks::empty(); + let mut ticks = Ticks::new(); ticks.mark_read(0, 76, t0); ticks.mark_read(76, 76, prompt); ticks.mark_read(76, 176, t1); @@ -660,7 +663,7 @@ mod tests { #[test] fn 同じ行数の目盛りは積まない() { let t0 = Instant::now(); - let mut ticks = Ticks::empty(); + let mut ticks = Ticks::new(); ticks.push(10, t0); ticks.push(10, t0 + Duration::from_secs(1)); assert_eq!(ticks.time_of(10), Some(t0), "先に見た時刻を残す"); @@ -669,7 +672,7 @@ mod tests { #[test] fn 目盛りは一秒おきに積む() { let t0 = Instant::now(); - let mut ticks = Ticks::empty(); + let mut ticks = Ticks::new(); ticks.push(10, t0); ticks.push(20, t0 + Duration::from_millis(500)); assert_eq!(ticks.time_of(20), Some(t0), "1 秒経っていないので積まない"); @@ -684,7 +687,7 @@ mod tests { #[test] fn 目盛りは古いものから捨てる() { let t0 = Instant::now(); - let mut ticks = Ticks::empty(); + let mut ticks = Ticks::new(); for i in 0..(TICKS as u64 + 100) { ticks.push(i, t0 + Duration::from_secs(i)); } diff --git a/src/scan.rs b/src/scan.rs index 159cc15..2a218a3 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -9,6 +9,7 @@ //! 単位に注意する。`find_hit` が返すのは元の文字列の**バイト**位置、 //! `window_around` が受け取り返すのは**文字**の添字である。 +use std::cmp::Ordering; use std::ops::Range; use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; use std::sync::{Arc, Mutex}; @@ -154,20 +155,24 @@ pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits for target in targets { scan_one(target, &needle, redactor, limits, &mut hits); } - // 新しい順に並べる。先頭に置くのは名前・題名・枝で、時刻が無いからではなく - // 行ではないからである。行の時刻は引けないこともある(目盛りの無い本、 - // 目盛りを 1 つも持たない代替画面の本)。その行は後ろへ回す。 - // 分からない行を、分かっている新しい行の上に出さない。 // 安定な並べ替えなので、同じ時刻のあいだは走った順=本ごとの新しい順が残る。 - hits.sort_by(|a, b| { - let pinned = |h: &Hit| h.kind != HitKind::Line; - // 時刻は `None` が小さいので、新しい順に並べれば後ろへ回る。 - pinned(b).cmp(&pinned(a)).then_with(|| b.at.cmp(&a.at)) - }); + hits.sort_by(newest_first); hits.truncate(limits.total); hits } +/// 一覧の並び。名前・題名・枝を先頭に置き、残りを新しい順に並べる。 +/// +/// 先頭に置くのは、それが時刻を持たないからではなく、行ではないからである。 +/// 時刻の引けない行は逆に後ろへ回す。分からない行を、分かっている新しい行の上に +/// 出さないためである。`Ticks` は本を開いた時刻を必ず答えるので、いま行が時刻なしで +/// 届くことはない。この分岐は守りとして置いてある。 +fn newest_first(a: &Hit, b: &Hit) -> Ordering { + let pinned = |h: &Hit| h.kind != HitKind::Line; + // 時刻は `None` が小さいので、新しい順に並べれば後ろへ回る。 + pinned(b).cmp(&pinned(a)).then_with(|| b.at.cmp(&a.at)) +} + /// 1 本を走る。名前・題名・枝を見てから、行を遡る。 fn scan_one( target: &Target, @@ -371,13 +376,20 @@ mod tests { crate::term::new_term(size, scrollback, proxy) } - /// 台本を流した `Target` を 1 本作る。目盛りは呼ぶ側が渡す。 + /// 台本を流した `Target` を 1 本作る。 /// + /// `at` を渡すとその時刻の目盛りを 1 つだけ置く。目盛りが 1 つならどの行も + /// その時刻になるので、本どうしの新しさを比べるのに足りる。`None` なら + /// 目盛りの無い本になり、行の時刻は `Ticks` の作った時刻になる。 /// 押し出した行数は走査では読むだけなので、試験では 0 のままにする。 - fn target_with(id: SessionId, play: &[u8], ticks: Ticks) -> Target { + 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, at); + } Target { id, term: Arc::new(FairMutex::new(term)), @@ -389,21 +401,6 @@ mod tests { } } - /// 台本を流した `Target`。 - /// - /// `at` を渡すとその時刻の目盛りを 1 つだけ置く。目盛りが 1 つならどの行も - /// その時刻になるので、本どうしの新しさを比べるのに足りる。`None` なら - /// 目盛りの無い本になり、行から時刻が引けない。 - /// 環は `Ticks::empty` から作る。`Ticks::new` の種が入っていると、 - /// 置いた目盛りが「同じ行数は積まない」で落ちてしまう。 - fn target(id: SessionId, play: &[u8], at: Option) -> Target { - let mut ticks = Ticks::empty(); - if let Some(at) = at { - ticks.mark_read(0, 1, at); - } - target_with(id, play, ticks) - } - /// 伏せる式を 1 つも持たない `Redactor`。行の走りだけを見たいとき。 fn plain() -> Redactor { Redactor::new(&[]).expect("式が無ければ組み立てられる") @@ -441,26 +438,58 @@ mod tests { assert_eq!(hits[1].id, 1); } + /// 並べ替えだけを見たい当たり。 + fn hit(kind: HitKind, at: Option) -> Hit { + Hit { + id: 1, + kind, + serial: at.map(|_| 0), + at, + text: String::new(), + hit: 0..0, + } + } + #[test] fn 時刻の無い行は後ろへ回す() { - // 時刻の引けない行を、分かっている新しい行の上に出さない。 - // 先頭に来るのは名前・題名・枝だけである。 + // 先頭に来るのは名前・題名・枝だけで、時刻の引けない行は後ろへ回る。 + // `Ticks` が作った時刻を必ず答えるので、`scan` を通って時刻の無い行が + // 出ることはない。並べ替えの守りを直に試す。 let t0 = Instant::now(); - let dated = target(1, &play(&["ses 時刻あり"]), Some(t0)); - let mut undated = target(2, &play(&["ses 時刻なし"]), None); - undated.name = "ses の本".to_string(); - let hits = scan(&[dated, undated], "ses", &plain(), Limits::default()); - assert_eq!(hits.len(), 3, "{hits:?}"); + let mut hits = [ + hit(HitKind::Line, None), + hit(HitKind::Line, Some(t0)), + hit(HitKind::Name, None), + ]; + hits.sort_by(newest_first); assert_eq!(hits[0].kind, HitKind::Name, "名前は先頭に残る"); assert_eq!(hits[1].at, Some(t0), "時刻の分かる行が先"); - assert_eq!(hits[2].at, None, "{hits:?}"); + assert_eq!(hits[2].at, None, "時刻の引けない行は後ろ"); + } + + #[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::new` の種で - // 行から時刻が引ける。引けないと一覧の並びが決まらない。 - let t = target_with(1, &play(&["ses one"]), Ticks::new()); + // 開いたばかりの本は 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:?}"); From 6f94d24e3dbf7273b26a52a24781599610c74cc9 Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 22:53:54 +0900 Subject: [PATCH 12/30] 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 --- src/main.rs | 236 ++++++++++++++++++++++++++++++++++++++++++++++++- src/probe.rs | 4 +- src/scan.rs | 2 - src/session.rs | 4 +- src/term.rs | 5 ++ 5 files changed, 242 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index cb33421..6348643 100644 --- a/src/main.rs +++ b/src/main.rs @@ -158,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}"); @@ -267,6 +268,62 @@ 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, +} + +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; + true + } + + /// 選んでいる件が見える範囲に入るよう、出す位置を合わせる。 + /// + /// `rows` は結果を置ける件数。1 件が 2 段を取るので、左ペインの高さで変わる。 + // 呼ぶのは一覧を動かす仕事(↑ と ↓)。それまで読み手がいない。 + #[allow(dead_code)] + 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; + } + } +} + pub(crate) struct State { renderer: Renderer, manager: Manager, @@ -281,6 +338,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)>, /// そのうち、いま選んでいる一致のセル。 @@ -307,7 +371,9 @@ pub(crate) struct State { /// 窓が完全に隠れているか。隠れているあいだは組み立てもしない。 occluded: bool, /// 画面で伏せる式。起動時に 1 度だけ組み立てる。 - pub(crate) redactor: crate::secret::Redactor, + /// + /// 横断検索の走査スレッドにも同じものを渡すので `Arc` で持つ。 + pub(crate) redactor: Arc, /// セッションの並びが変わった。少し置いてから書き出す。 state_dirty: bool, /// 最後に書き出した時刻。 @@ -437,7 +503,7 @@ struct App { /// 返事待ちを最後に調べた時刻。毎フレーム画面を読まないための間隔。 agent_state_at: Option, /// 画面を作るときに State へ渡す式。組み立ては起動時に済ませてある。 - redactor_seed: crate::secret::Redactor, + redactor_seed: Arc, } /// 画面の割り付け。すべてセル単位で扱う。 @@ -472,6 +538,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(); } @@ -512,6 +581,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(), @@ -642,6 +713,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(); @@ -1619,6 +1701,9 @@ fn wants_redraw( UiEvent::Command(id, _) => shown(*id), // 目に見えるものは変わらない。 UiEvent::ClipboardStore(_, _) | UiEvent::ClipboardLoad(_, _) => false, + // 古い世代なら一覧は変わらない。受け取れたかどうかで分かれるので、 + // 描き直しは受け取る分岐が決める。 + UiEvent::Scanned(_, _) => false, } } @@ -1885,6 +1970,67 @@ impl App { search.offset = 0; } + /// 語が変わったことを覚える。走らせるのは手が止まってからである(仕様 §10)。 + // 呼ぶのは箱の鍵を受ける仕事。それまで読み手がいない。 + #[allow(dead_code)] + 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; + // 語が空白だけなら走らせない。一覧はセッションのものに戻っている。 + if across.query.trim().is_empty() { + return; + } + state.scan_generation += 1; + across.generation = state.scan_generation; + let generation = across.generation; + let query = across.query.clone(); + 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}"); + } + } + // ---------------------------------------------------------------- 描画 fn draw(&mut self, event_loop: &ActiveEventLoop) { @@ -2004,6 +2150,20 @@ impl App { want(std::time::Instant::now() + WORKING_REFRESH); } } + // 横断検索の語が変わっているなら、手が止まったころに走らせる。 + let scan_due = self + .state + .as_ref() + .and_then(|s| s.across.as_ref()) + .and_then(|across| across.typed_at) + .map(|at| at + SCAN_DEBOUNCE); + if let Some(due) = scan_due { + if due <= std::time::Instant::now() { + self.start_scan(); + } else { + want(due); + } + } if let Some(at) = wake { event_loop.set_control_flow(ControlFlow::WaitUntil(at)); } @@ -3306,6 +3466,12 @@ mod tests { Some(3), false )); + // 走査の結果は、受け取れたかどうかで変わる。ここでは決めない。 + assert!(!wants_redraw( + &UiEvent::Scanned(1, Vec::new()), + Some(3), + false + )); } fn px(y: f64) -> MouseScrollDelta { @@ -3426,6 +3592,70 @@ 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 選んでいる件が窓から出ないよう追う() { + // 窓は 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); + } + } } #[cfg(test)] diff --git a/src/probe.rs b/src/probe.rs index 4cbaedc..9ac0033 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(), diff --git a/src/scan.rs b/src/scan.rs index 2a218a3..e5962ed 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -140,8 +140,6 @@ impl Default for Limits { /// /// 走るのは別スレッドである(仕様 §10)。1 フレームの余白は 0.3ms ほどしかなく、 /// 10000 行の走査はそこに入らない。 -// 呼ぶのは次の仕事(別スレッドで走らせ、結果を UI へ渡す)。それまで読み手がいない。 -#[allow(dead_code)] pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits) -> Vec { // 針も 1 文字ずつ小文字にする。`find_hit` は干し草を `char::to_lowercase` で // 小文字にするので、こちらだけ `str::to_lowercase` を使うと食い違う。 diff --git a/src/session.rs b/src/session.rs index 5a328fb..48ffaee 100644 --- a/src/session.rs +++ b/src/session.rs @@ -46,11 +46,9 @@ pub struct Session { pub activity: Arc, /// これまでに履歴へ押し出した行の数。行に通し番号を付けるのに使う。 /// - /// 読み手は左ペインの検索で、まだ入っていない。 - #[allow(dead_code)] + /// 読むのは左ペインの横断検索で、走査スレッドへ `Arc` の複製を渡す。 pub pushed: Arc, /// 押し出した行数と時刻の対応。通し番号から時刻を引くのに使う。 - #[allow(dead_code)] pub ticks: Arc>, /// 左ペインに「動いている」と描いてあるか。 /// 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 へ書き戻す。 From 9a09ef809b14df9ee5eb5fa3d22b9daf0a7786ab Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 23:10:10 +0900 Subject: [PATCH 13/30] 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 --- src/main.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6348643..527d98b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -308,6 +308,18 @@ impl CrossSearch { true } + /// 出ている当たりを捨てる。捨てるものがあったなら `true`。 + /// + /// 語を消し切ったときに呼ぶ。`hits` を書くのは `accept` とここだけなので、 + /// ここで捨てなければ、消した語の当たりがいつまでも一覧に残る。 + fn clear_hits(&mut self) -> bool { + let had = !self.hits.is_empty(); + self.hits.clear(); + self.selected = 0; + self.offset = 0; + had + } + /// 選んでいる件が見える範囲に入るよう、出す位置を合わせる。 /// /// `rows` は結果を置ける件数。1 件が 2 段を取るので、左ペインの高さで変わる。 @@ -324,6 +336,37 @@ impl CrossSearch { } } +/// 走査を始める頃合いか、まだ待つか。 +#[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, @@ -1994,12 +2037,20 @@ impl App { }; // 走らせるかどうかに関わらず、待ちは解く。残すと過ぎた時刻で起こされ続ける。 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; } - state.scan_generation += 1; - across.generation = state.scan_generation; let generation = across.generation; let query = across.query.clone(); let targets: Vec = state @@ -2151,18 +2202,15 @@ impl App { } } // 横断検索の語が変わっているなら、手が止まったころに走らせる。 - let scan_due = self + let typed_at = self .state .as_ref() .and_then(|s| s.across.as_ref()) - .and_then(|across| across.typed_at) - .map(|at| at + SCAN_DEBOUNCE); - if let Some(due) = scan_due { - if due <= std::time::Instant::now() { - self.start_scan(); - } else { - want(due); - } + .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)); @@ -3633,6 +3681,38 @@ mod tests { 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 件。下へ出たら、下端に付いてくる。 From c681312121d7922721b29561ff95c72604de1c4b Mon Sep 17 00:00:00 2001 From: tkc Date: Wed, 28 Oct 2026 23:41:39 +0900 Subject: [PATCH 14/30] feat: put the search box in the left pane and list the hits there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.rs | 697 ++++++++++++++++++++++++++++++++++++++++++++++---- src/probe.rs | 52 ++++ src/render.rs | 3 - src/scan.rs | 5 +- 4 files changed, 705 insertions(+), 52 deletions(-) diff --git a/src/main.rs b/src/main.rs index 527d98b..8a2154d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,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}; @@ -323,8 +323,7 @@ impl CrossSearch { /// 選んでいる件が見える範囲に入るよう、出す位置を合わせる。 /// /// `rows` は結果を置ける件数。1 件が 2 段を取るので、左ペインの高さで変わる。 - // 呼ぶのは一覧を動かす仕事(↑ と ↓)。それまで読み手がいない。 - #[allow(dead_code)] + /// 呼ぶ側は `across_window` で出すこと。割り付けと食い違うと件が隠れる。 fn follow(&mut self, rows: usize) { // 1 件も置けない高さでも、選んでいる件を追い越さない。 let rows = rows.max(1); @@ -934,6 +933,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); @@ -1026,6 +1030,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 { @@ -1139,8 +1150,19 @@ impl App { state.search = None; state.find = Some(search::ScreenSearch::new()); } - // 横断検索の箱はまだ無い。キーだけ先に分けてある。 - Action::FindAcrossSessions => {} + Action::FindAcrossSessions => { + 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(); + } + } Action::SearchHistory => { state.picker = None; state.find = None; @@ -1525,11 +1547,37 @@ impl App { } } SidebarHit::Close(i) => state.manager.close(i), + SidebarHit::SearchBox => { + // 窓に入る(仕様 §9)。入っているなら、そのままにする。 + if state.across.is_none() { + state.find = None; + // 掴んでいた行は放す。結果の一覧に並べ替えは効かせない。 + state.mouse.sidebar_drag = None; + state.across = Some(CrossSearch::default()); + } + } + 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); + } + } + } } 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); @@ -2013,9 +2061,69 @@ 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) => 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(); + } + _ => { + 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)。 - // 呼ぶのは箱の鍵を受ける仕事。それまで読み手がいない。 - #[allow(dead_code)] fn queue_scan(&mut self) { let Some(across) = self.state.as_mut().and_then(|s| s.across.as_mut()) else { return; @@ -2294,6 +2402,11 @@ mod sidebar { pub const RADIUS: f32 = 6.0; /// 上端に `+` を置く帯の高さ。 pub const HEADER: f32 = 34.0; + /// 帯の右端で `+` が取る幅。残りが検索窓になる(仕様 §6)。 + /// + /// 描く側と当たり判定で同じ値を使う。食い違うと、窓を押したのに + /// セッションが増える。 + pub const PLUS_ZONE: f32 = 24.0; /// 区切り線の上下の余白。 pub const SEP_PAD: f32 = 8.0; @@ -2305,6 +2418,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` で写す文字を決める。 @@ -2357,6 +2477,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 の一覧の見え方に近くなる。 @@ -2369,8 +2503,10 @@ fn style(size: f32, bold: bool) -> TextStyle { } } -/// 左ペインの 1 セッションぶんの領域。描画と当たり判定で同じ値を使う。 +/// 左ペインの 1 件ぶんの領域。描画と当たり判定で同じ値を使う。 struct Block { + /// セッションの一覧では `sessions()` の添字。結果の一覧では + /// `across.hits` の添字で、送った位置(`offset`)を足した絶対の値。 index: usize, depth: usize, /// 矩形の上端(画素)。 @@ -2390,39 +2526,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 { @@ -2459,8 +2698,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), } /// 画素の位置で当たりを見る。 @@ -2471,14 +2714,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); @@ -2492,6 +2743,290 @@ fn sidebar_hit(state: &State, layout: &Layout, px: f32, py: f32) -> Option, + exit: Option, + 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, 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, s.is_working(), s.blocked) + } + // 終わって消えたセッション。止まっている印で出す。 + None => ( + CLOSED_SESSION.to_string(), + false, + None, + Some(0), + false, + false, + ), + }; + Some(HitRow { + top: b.top, + height: b.height, + selected: b.index == across.selected, + name, + is_path, + label, + exit, + 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` は時刻を + // 押すだけ)、走らせる前に「無い」と言わない。 + let empty = state.across.as_ref().is_some_and(|a| a.hits.is_empty()); + let waiting = state.across.as_ref().is_some_and(|a| a.typed_at.is_some()); + if empty { + let note = if waiting { "searching…" } else { "no match" }; + 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 fg = if row.exit.is_none() { + 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 row.exit.is_none() { "●" } 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); @@ -2500,6 +3035,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 @@ -2514,6 +3052,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()); @@ -2600,22 +3152,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), ); // 右端は、ふだんは ⌘ の番号、カーソルが乗っているときは閉じる印。 // 両方を常に置くと名前の幅が足りない。 @@ -2710,7 +3253,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; } @@ -2888,10 +3436,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 { @@ -3791,6 +4345,57 @@ 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 高さに入る件数だけ席を作る() { + // 帯の下(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 9ac0033..d5a3b3c 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -204,6 +204,58 @@ 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"; + let needle = query.to_lowercase(); + 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/render.rs b/src/render.rs index 26d4642..c792272 100644 --- a/src/render.rs +++ b/src/render.rs @@ -615,8 +615,6 @@ impl Renderer { /// `hit` は `text` の**バイト**位置。返す範囲は、返す文字列の中での当たりの /// **バイト**位置で、先頭の省略記号のぶんだけずれる。当たりが残せる幅より /// 長ければ、収まる分だけに切って返す。 - // 呼ぶのは結果の行を描く仕事。それまで読み手がいない。 - #[allow(dead_code)] pub fn fit_center( &mut self, text: &str, @@ -1165,7 +1163,6 @@ fn build(chars: &[char], keep: usize, drop_tail: bool) -> String { /// /// `hit_chars` は文字の添字、`hit` は `text` のバイト位置。戻りの範囲は /// 組み立てた文字列のバイト位置で、残した窓に収まる分に切ってある。 -#[allow(dead_code)] fn build_window( text: &str, chars: &[char], diff --git a/src/scan.rs b/src/scan.rs index e5962ed..2100889 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -60,7 +60,6 @@ pub fn find_hit(text: &str, needle_lower: &str) -> Option> { /// 当たりの前後へ均等に伸ばし、片側が尽きたら残りをもう片側へ回す。 /// 当たりが `keep` より長ければ、当たりの頭から `keep` 字を残す。 /// 戻り値の 2 つの真偽は、頭を落としたか・尾を落としたか。 -#[allow(dead_code)] pub fn window_around(chars: &[char], hit: Range, keep: usize) -> (Range, bool, bool) { let len = chars.len(); if keep >= len { @@ -100,12 +99,12 @@ pub enum HitKind { /// 1 件の当たり。1 件は 1 当たりで、セッション単位にはまとめない(仕様 §5)。 #[derive(Debug, Clone)] -// 読むのは結果を描く仕事と、選んで飛ぶ仕事。それまで読み手がいない。 -#[allow(dead_code)] pub struct Hit { pub id: SessionId, pub kind: HitKind, /// 当たった行の通し番号。名前・題名・枝と、代替画面の行なら `None`。 + // 読むのは選んで飛ぶ仕事。それまで試験のほかに読み手がいない。 + #[allow(dead_code)] pub serial: Option, /// その行が現れた時刻。目盛りが残っていなければ `None`。 pub at: Option, From eaa79af0b5665d67b29bbfa5cf21879836587ee9 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:04:14 +0900 Subject: [PATCH 15/30] 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 --- src/main.rs | 144 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 128 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8a2154d..6fdec1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -290,6 +290,46 @@ pub(crate) struct CrossSearch { 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 { @@ -305,6 +345,7 @@ impl CrossSearch { self.hits = hits; self.selected = 0; self.offset = 0; + self.scanning = false; true } @@ -314,12 +355,22 @@ impl CrossSearch { /// ここで捨てなければ、消した語の当たりがいつまでも一覧に残る。 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 段を取るので、左ペインの高さで変わる。 @@ -1555,6 +1606,8 @@ impl App { state.mouse.sidebar_drag = None; state.across = Some(CrossSearch::default()); } + // 並び順は変わらない。保存を予約しない。 + return; } SidebarHit::Hit(n) => { // 選び直すだけ。飛ぶのは別の仕事である。 @@ -1566,6 +1619,7 @@ impl App { across.follow(rows); } } + return; } } state.mark_state_dirty(); @@ -2101,6 +2155,14 @@ impl App { 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; }; @@ -2161,6 +2223,7 @@ impl App { } let generation = across.generation; let query = across.query.clone(); + across.scanning = true; let targets: Vec = state .manager .sessions() @@ -2187,6 +2250,9 @@ impl App { 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; + } } } @@ -2821,7 +2887,9 @@ fn draw_search_box(state: &mut State, sl: &SidebarLayout, theme: &Theme) { x += state .renderer .put_text_px(x, ty, &shown, st, theme.fg_primary); - x += draw_preedit_px(state, x, ty, st, theme); + // 箱の右端までに収める。下敷きは窓自身の色にする。 + let room = (sl.rect_x + box_w - pad - x).max(0.0); + x += draw_preedit_px(state, x, ty, st, theme, Some((room, theme.surface))); state .renderer .fill_px(x, ty, 2.0 * sc, line_h, theme.cursor, 0.0); @@ -2840,6 +2908,8 @@ struct HitRow { /// `⌘` の札。引けなかったセッションでは出さない。 label: Option, exit: Option, + /// 走査のあいだに終わって消えたセッション。終了コードは分からない。 + closed: bool, working: bool, blocked: bool, kind: scan::HitKind, @@ -2867,7 +2937,7 @@ fn collect_hit_rows(state: &State, sl: &SidebarLayout) -> Vec { .iter() .enumerate() .find(|(_, s)| s.id == hit.id); - let (name, is_path, label, exit, working, blocked) = match found { + 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 { @@ -2879,14 +2949,15 @@ fn collect_hit_rows(state: &State, sl: &SidebarLayout) -> Vec { .iter() .position(|r| r.index == i) .and_then(input::index_label); - (name, is_path, label, exit, s.is_working(), s.blocked) + (name, is_path, label, exit, false, s.is_working(), s.blocked) } // 終わって消えたセッション。止まっている印で出す。 None => ( CLOSED_SESSION.to_string(), false, None, - Some(0), + None, + true, false, false, ), @@ -2899,6 +2970,7 @@ fn collect_hit_rows(state: &State, sl: &SidebarLayout) -> Vec { is_path, label, exit, + closed, working, blocked, kind: hit.kind, @@ -2927,10 +2999,13 @@ fn draw_across_hits(state: &mut State, sl: &SidebarLayout, theme: &Theme) { // 当たりが 1 件も無いときは、何も無い理由を出す。 // 当たりは語より 1 走査ぶん遅れることがあるので(`queue_scan` は時刻を // 押すだけ)、走らせる前に「無い」と言わない。 - let empty = state.across.as_ref().is_some_and(|a| a.hits.is_empty()); - let waiting = state.across.as_ref().is_some_and(|a| a.typed_at.is_some()); - if empty { - let note = if waiting { "searching…" } else { "no match" }; + // 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); @@ -2961,7 +3036,8 @@ fn draw_across_hits(state: &mut State, sl: &SidebarLayout, theme: &Theme) { } else { state.renderer.fit_tail(&row.name, title_st, avail) }; - let fg = if row.exit.is_none() { + let ended = row.closed || row.exit.is_some(); + let fg = if !ended { theme.fg_primary } else { theme.fg_tertiary @@ -2970,7 +3046,7 @@ fn draw_across_hits(state: &mut State, sl: &SidebarLayout, theme: &Theme) { state.renderer.put_text_px( sl.rect_x + sidebar::MARK_INSET * sc, y + (title_h - small_h) / 2.0, - if row.exit.is_none() { "●" } else { "○" }, + if ended { "○" } else { "●" }, small_st, mark_color(theme, row.exit, row.working, row.blocked), ); @@ -3524,7 +3600,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); @@ -3564,7 +3640,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); @@ -3667,7 +3743,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); @@ -3708,14 +3784,26 @@ 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_tail(&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); @@ -4380,6 +4468,30 @@ mod search_view_tests { 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!(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 画素。 From 3b251dbc1830f06156fca089aebf532672053af4 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:07:58 +0900 Subject: [PATCH 16/30] 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 --- src/main.rs | 38 ++++++++++++++++++++++++++++++++++---- src/probe.rs | 5 +++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6fdec1a..6d898c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2473,6 +2473,11 @@ mod sidebar { /// 描く側と当たり判定で同じ値を使う。食い違うと、窓を押したのに /// セッションが増える。 pub const PLUS_ZONE: f32 = 24.0; + /// 変換中に、検索窓の中で変換中の文字列へ必ず残す幅。 + /// + /// 確定した語が窓いっぱいに伸びていると、変換中の文字列に幅が残らず、 + /// いま打っている字が見えなくなる。全角で 4 字ぶん(12pt)より少し広く取る。 + pub const MIN_PREEDIT: f32 = 56.0; /// 区切り線の上下の余白。 pub const SEP_PAD: f32 = 8.0; @@ -2883,13 +2888,15 @@ fn draw_search_box(state: &mut State, sl: &SidebarLayout, theme: &Theme) { .map(|a| a.query.clone()) .unwrap_or_default(); // 打っている末尾を残す。先頭が落ちる。 - let shown = state.renderer.fit_head(&query, st, avail); + // 変換中は、変換中の文字列のぶんを先に取り分ける。 + let (query_w, preedit_w) = + split_box_width(avail, !state.preedit.is_empty(), sidebar::MIN_PREEDIT * sc); + let shown = state.renderer.fit_head(&query, st, query_w); x += state .renderer .put_text_px(x, ty, &shown, st, theme.fg_primary); // 箱の右端までに収める。下敷きは窓自身の色にする。 - let room = (sl.rect_x + box_w - pad - x).max(0.0); - x += draw_preedit_px(state, x, ty, st, theme, Some((room, theme.surface))); + x += draw_preedit_px(state, x, ty, st, theme, Some((preedit_w, theme.surface))); state .renderer .fill_px(x, ty, 2.0 * sc, line_h, theme.cursor, 0.0); @@ -2897,6 +2904,17 @@ fn draw_search_box(state: &mut State, sl: &SidebarLayout, theme: &Theme) { state.cursor_px = Some((x, ty)); } +/// 窓の中の幅を、確定した語と変換中の文字列へ分ける。 +/// 変換中は、いま打っている末尾が必ず見えるだけの幅を残す。 +fn split_box_width(avail: f32, composing: bool, min_preedit: f32) -> (f32, f32) { + let avail = avail.max(0.0); + if !composing { + return (avail, 0.0); + } + let preedit = min_preedit.min(avail); + (avail - preedit, preedit) +} + /// 結果の 1 件を描くのに要るもの。`renderer` を触る前に写し取る。 struct HitRow { top: f32, @@ -3798,7 +3816,8 @@ fn draw_preedit_px( return 0.0; } let (text, under) = match bound { - Some((room, color)) => (state.renderer.fit_tail(&state.preedit, st, room), color), + // 窓の中では、いま打っている末尾を残す。他の呼び手は切らない。 + 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); @@ -4485,6 +4504,17 @@ mod search_view_tests { 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 件が置けない高さでも理由を出す() { assert_eq!(across_note_text(true, &ScanNote::None), Some("too narrow")); diff --git a/src/probe.rs b/src/probe.rs index d5a3b3c..c34dd16 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -253,8 +253,9 @@ pub fn run(out_path: &str) { selected: 1, ..Default::default() }); - // 変換中の文字列が窓へ出ることも確かめる。 - state.preedit = "まっち".into(); + // 変換中の文字列が窓へ出ることも確かめる。窓に収まらない長さにして、 + // 切れ方(末尾が残ること)と、語が窓いっぱいでも幅が残ることを見る。 + state.preedit = "けんさくごをながくへんかんしているところ".into(); } Ok("preedit") => { state.preedit = "あっだ".into(); From d6aee94c7e83ab864349041edb64a85d7ed88a54 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:10:34 +0900 Subject: [PATCH 17/30] 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 --- src/main.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6d898c8..386df60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2889,14 +2889,15 @@ fn draw_search_box(state: &mut State, sl: &SidebarLayout, theme: &Theme) { .unwrap_or_default(); // 打っている末尾を残す。先頭が落ちる。 // 変換中は、変換中の文字列のぶんを先に取り分ける。 - let (query_w, preedit_w) = - split_box_width(avail, !state.preedit.is_empty(), sidebar::MIN_PREEDIT * sc); + let (query_w, _) = split_box_width(avail, !state.preedit.is_empty(), sidebar::MIN_PREEDIT * sc); let shown = state.renderer.fit_head(&query, st, query_w); - x += state + let used = state .renderer .put_text_px(x, ty, &shown, st, theme.fg_primary); + x += used; // 箱の右端までに収める。下敷きは窓自身の色にする。 - x += draw_preedit_px(state, x, ty, st, theme, Some((preedit_w, theme.surface))); + let room = preedit_room(avail, used); + x += draw_preedit_px(state, x, ty, st, theme, Some((room, theme.surface))); state .renderer .fill_px(x, ty, 2.0 * sc, line_h, theme.cursor, 0.0); @@ -2915,6 +2916,14 @@ fn split_box_width(avail: f32, composing: bool, min_preedit: f32) -> (f32, f32) (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, @@ -4515,6 +4524,18 @@ mod search_view_tests { 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")); From feb015a2c893bac9895d50b8f74171d168fecf79 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:12:57 +0900 Subject: [PATCH 18/30] feat: jump to a hit by its serial and fall back to find Co-Authored-By: Claude Opus 5 --- src/main.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++-- src/scan.rs | 36 ++++++++++++-------- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 386df60..2739d35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2021,6 +2021,63 @@ impl App { } } + /// 結果の `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 ids: Vec<_> = state.manager.sessions().iter().map(|s| s.id).collect(); + let Some(index) = position_of(&ids, hit.id) else { + return; + }; + state.manager.select(index); + let mut find = search::ScreenSearch::new(); + find.query = query.clone(); + find.rebuild(); + state.find = Some(find); + state.across = None; + + let Some(serial) = hit.serial.filter(|_| hit.kind == scan::HitKind::Line) else { + return; + }; + let needle = query.to_lowercase(); + // 錠は 1 度の短い読みだけで放す。`pushed` はグリッドと同じ錠の下で読む。 + let target = state.manager.selected().and_then(|session| { + let term = session.term.lock(); + let pushed = session.pushed.load(std::sync::atomic::Ordering::Relaxed); + use alacritty_terminal::grid::Dimensions as _; + let grid = term.grid(); + let history = grid.history_size(); + let line = jump_target(Some(serial), pushed, history)?; + if line > grid.screen_lines() as i32 - 1 { + return None; + } + // 出力が流れていても番号から引き直した行を読む。語がもう無ければ + // 数え違い・書き換えなので、送らずに `find_step` へ任せる。 + let mut text = String::new(); + scan::row_text(grid, line, &mut text); + scan::find_hit(&text, &needle).map(|_| line) + }); + match target { + Some(line) => { + if let Some(session) = state.manager.selected() { + session.term.lock().scroll_to_point(Point::new( + alacritty_terminal::index::Line(line), + Column(0), + )); + } + } + None => self.find_step(Direction::Left), + } + } + 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 { @@ -2134,8 +2191,11 @@ impl App { state.across = None; return; } - // 飛ぶのは別の仕事である。ここで受けて、字として入らないようにする。 - Key::Named(NamedKey::Enter) => 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(); @@ -4408,10 +4468,41 @@ mod tests { } } +/// 通し番号がいまどの行位置か。番号が無い件と、履歴から消えた件は `None`。 +fn jump_target(serial: Option, pushed: u64, history: usize) -> Option { + serial.and_then(|serial| lines::line_of(serial, pushed, history)) +} + +/// `id` のセッションが並びの何番目か。閉じていれば `None`。 +fn position_of(ids: &[crate::session::SessionId], id: crate::session::SessionId) -> Option { + ids.iter().position(|&i| i == id) +} + #[cfg(test)] mod search_view_tests { use super::*; + #[test] + fn 流れた行は通し番号から引き直す() { + assert_eq!(jump_target(Some(97), 100, 10000), Some(-3)); + // 30 行流れたあとは、同じ番号が 30 行ぶん上にある。 + assert_eq!(jump_target(Some(97), 130, 10000), Some(-33)); + } + + #[test] + fn 消えた行なら送らずに語だけ渡す() { + 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 閉じたセッションの件は何もしない() { + let ids = [1, 3]; + assert_eq!(position_of(&ids, 3), Some(1)); + assert_eq!(position_of(&ids, 2), None); + } + fn view(selected: usize, offset: usize) -> SearchState { SearchState { query: String::new(), diff --git a/src/scan.rs b/src/scan.rs index 2100889..5677ea9 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -103,8 +103,6 @@ pub struct Hit { pub id: SessionId, pub kind: HitKind, /// 当たった行の通し番号。名前・題名・枝と、代替画面の行なら `None`。 - // 読むのは選んで飛ぶ仕事。それまで試験のほかに読み手がいない。 - #[allow(dead_code)] pub serial: Option, /// その行が現れた時刻。目盛りが残っていなければ `None`。 pub at: Option, @@ -274,18 +272,7 @@ fn scan_lines( at -= 1; walked += 1; - text.clear(); - for cell in &grid[Line(row)][..] { - // 全角の後ろ半分は実体を持たないコマなので飛ばす - // (`session.rs` の `grid_text` と同じ規則)。 - if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { - continue; - } - text.push(cell.c); - } - while text.ends_with(' ') { - text.pop(); - } + row_text(grid, row, &mut text); if text.is_empty() { continue; } @@ -329,6 +316,27 @@ fn scan_lines( } } +/// グリッドの 1 行を文字列にする。行末の空白は落とす。 +/// +/// 全角の後ろ半分は実体を持たないコマなので飛ばす(`session.rs` の `grid_text` と +/// 同じ規則)。走査と、選んで飛ぶときの確かめが同じ読み方をするよう、ここに 1 つだけ置く。 +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 本ほど走らせる。 From 9ca0cb220749a0e2a18b732d71373b06fc564516 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:26:46 +0900 Subject: [PATCH 19/30] 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 --- src/main.rs | 98 +++++++++++++++++++++++++++++++---------------------- src/scan.rs | 28 +++++++++++---- 2 files changed, 80 insertions(+), 46 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2739d35..172b106 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2033,8 +2033,7 @@ impl App { }; let query = across.query.clone(); // 閉じたセッションの件なら、何も動かさず窓も閉じない。 - let ids: Vec<_> = state.manager.sessions().iter().map(|s| s.id).collect(); - let Some(index) = position_of(&ids, hit.id) else { + let Some(index) = state.manager.sessions().iter().position(|s| s.id == hit.id) else { return; }; state.manager.select(index); @@ -2044,29 +2043,35 @@ impl App { state.find = Some(find); state.across = None; - let Some(serial) = hit.serial.filter(|_| hit.kind == scan::HitKind::Line) else { - return; - }; - let needle = query.to_lowercase(); - // 錠は 1 度の短い読みだけで放す。`pushed` はグリッドと同じ錠の下で読む。 - let target = state.manager.selected().and_then(|session| { + let serial = hit.serial.filter(|_| hit.kind == scan::HitKind::Line); + let needle = scan::needle(&query); + use alacritty_terminal::grid::Dimensions as _; + // 錠は 1 度の短い読みだけで放す。`pushed` は走査と同じ読み方で、錠の中で読む。 + // 読み手は `term` を放してから `pushed` を書くので 1 読みぶんずれうるが、 + // そのずれは下の行の再確認で捕まえる。 + let landed = state.manager.selected().map(|session| { let term = session.term.lock(); let pushed = session.pushed.load(std::sync::atomic::Ordering::Relaxed); - use alacritty_terminal::grid::Dimensions as _; let grid = term.grid(); - let history = grid.history_size(); - let line = jump_target(Some(serial), pushed, history)?; - if line > grid.screen_lines() as i32 - 1 { - return None; - } - // 出力が流れていても番号から引き直した行を読む。語がもう無ければ - // 数え違い・書き換えなので、送らずに `find_step` へ任せる。 - let mut text = String::new(); - scan::row_text(grid, line, &mut text); - scan::find_hit(&text, &needle).map(|_| line) + 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 target { - Some(line) => { + 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), @@ -2074,7 +2079,10 @@ impl App { )); } } - None => self.find_step(Direction::Left), + // 数え違い・書き換え。 + Some((Landing::Search | Landing::Row(_), _)) => self.find_step(Direction::Left), + // 送り先が無い。語を渡すだけで画面は動かさない。 + Some((Landing::Nothing, _)) | None => {} } } @@ -4468,14 +4476,28 @@ mod tests { } } -/// 通し番号がいまどの行位置か。番号が無い件と、履歴から消えた件は `None`。 -fn jump_target(serial: Option, pushed: u64, history: usize) -> Option { - serial.and_then(|serial| lines::line_of(serial, pushed, history)) +/// `Enter` を押したとき、その件をどう扱うか。 +#[derive(Debug, PartialEq, Eq)] +enum Landing { + /// 送り先が無い。セッションを切り替えて語を渡すだけ(仕様 §12)。 + /// 名前・題名・枝の件と、履歴から消えた行。 + Nothing, + /// 数え違い。`⌘F` に探させる。 + Search, + /// この行を見に行く。まだ語が有れば送り、無ければ `Search` と同じにする。 + Row(i32), } -/// `id` のセッションが並びの何番目か。閉じていれば `None`。 -fn position_of(ids: &[crate::session::SessionId], id: crate::session::SessionId) -> Option { - ids.iter().position(|&i| i == id) +/// 通し番号を、いまの `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)] @@ -4483,24 +4505,20 @@ mod search_view_tests { use super::*; #[test] - fn 流れた行は通し番号から引き直す() { - assert_eq!(jump_target(Some(97), 100, 10000), Some(-3)); - // 30 行流れたあとは、同じ番号が 30 行ぶん上にある。 - assert_eq!(jump_target(Some(97), 130, 10000), Some(-33)); + fn 消えた行は送らずに語だけ渡す() { + assert_eq!(landing(None, 100, 10000, 24), Landing::Nothing); + assert_eq!(landing(Some(97), 120, 10, 24), Landing::Nothing); } #[test] - fn 消えた行なら送らずに語だけ渡す() { - 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)); + 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 閉じたセッションの件は何もしない() { - let ids = [1, 3]; - assert_eq!(position_of(&ids, 3), Some(1)); - assert_eq!(position_of(&ids, 2), None); + fn 画面より下に出た件は数え違いとして扱う() { + assert_eq!(landing(Some(200), 120, 10000, 24), Landing::Search); } fn view(selected: usize, offset: usize) -> SearchState { diff --git a/src/scan.rs b/src/scan.rs index 5677ea9..ecf65ba 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -133,16 +133,22 @@ impl Default for Limits { } } +/// 打った語から `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 { - // 針も 1 文字ずつ小文字にする。`find_hit` は干し草を `char::to_lowercase` で - // 小文字にするので、こちらだけ `str::to_lowercase` を使うと食い違う。 - // ギリシャ語の語末シグマがそれで、`str` は語末の 'Σ' を 'ς' にするが - // `char` は必ず 'σ' にする。打った語が黙って当たらなくなる。 - let needle: String = query.chars().flat_map(char::to_lowercase).collect(); + let needle = needle(query); if needle.trim().is_empty() { return Vec::new(); } @@ -346,7 +352,11 @@ pub fn row_text( /// 当たった行だけ伏せ字を通し、伏せたあとの文字列でもう一度探す。鍵の中にしか /// ない当たりはここで消える。端末で `••••` に見えている鍵が、結果の一覧に /// 素のまま出ることはない(仕様 §6)。 -fn matched(redactor: &Redactor, text: &str, needle: &str) -> Option<(String, Range)> { +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)?; @@ -364,6 +374,12 @@ mod tests { 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; From 00c745e8c6c81d166b3f40cb3029e061b991630b Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:35:08 +0900 Subject: [PATCH 20/30] 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 --- README.md | 10 ++++ docs/performance.md | 57 +++++++++++++++++++++++ src/bench.rs | 108 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7d62f2..cc4e676 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,15 @@ 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`, most full-screen agent UIs) 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. + **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..db4cdc5 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -578,6 +578,62 @@ 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()` も無い。履歴の長さに比例する仕事は無い。 +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 行で、約 49ms を 547 塊(14 万行 / 256)で割ると、1 塊は約 90µs になる。 + これは割り算の見積もりで、錠の保持時間を直接測ったものではない。 + +**未了:実際のウィンドウでフレームの記録を見る確認。** この節を書いたのは窓も画面も無い環境で、 +`TERMIT_FRAME_LOG` を実機で見ていない。上の 3 つはコードを読んだ論証であり、 +「フレームが落ちなかった」という観測ではない。閉じるには、人が次を実行する。 + +```sh +RUST_LOG=info TERMIT_FRAME_LOG=1 cargo run --release +``` + +セッションを数本開き、それぞれに数千行を出してから `⌘⇧F` を押し、語を打って検索する。 +走査が走っているあいだの毎フレームの記録に、落ちたフレームが無いことを見る。 +あわせて、検索中も各セッションの出力が止まらないことを目で確かめる。 + ## 8. 参考にした資料 手法ごとの採否は [references/performance-techniques.md](references/performance-techniques.md) にまとめた。 @@ -604,3 +660,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..8ff5732 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; + // どの行にも現れない語。 + 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 + ); +} From 5f9dca55d03d13340b3ccb78dc07784ef4050f35 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 00:38:20 +0900 Subject: [PATCH 21/30] 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 --- README.md | 2 +- docs/performance.md | 5 +++-- src/bench.rs | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cc4e676..5f7071b 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ 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`, most full-screen agent UIs) can only be +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 diff --git a/docs/performance.md b/docs/performance.md index db4cdc5..44c214f 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -611,15 +611,16 @@ glyphon の `prepare` は `impl IntoIterator` を取る。 `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 件)のうち、 +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 行で、約 49ms を 547 塊(14 万行 / 256)で割ると、1 塊は約 90µs になる。 + 1 塊は 256 行で、塊は本ごとに切るので、全体で 14 x ceil(10024 / 256) = 560、約 550 塊になる。約 49ms を約 550 で割ると、1 塊は約 90µs になる。 これは割り算の見積もりで、錠の保持時間を直接測ったものではない。 **未了:実際のウィンドウでフレームの記録を見る確認。** この節を書いたのは窓も画面も無い環境で、 diff --git a/src/bench.rs b/src/bench.rs index 8ff5732..b7d9fb6 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -148,7 +148,7 @@ fn scan_cost() { 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) From 8133abd48d1fb463a08b3ce42234a8749b939b4d Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:19:49 +0900 Subject: [PATCH 22/30] fix: mark the tick where the read starts writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- src/lines.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++------- src/pty.rs | 22 +++++++++++++++----- src/scan.rs | 7 +++++-- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index 64151be..8eb7a95 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -87,18 +87,32 @@ impl Ticks { } } - /// 1 回の読みを目盛りに残す。`before` は読む前、`after` は読んだ後の行数。 + /// 1 回の読みを目盛りに残す。`before` は読む前、`after` は読んだ後の行数で、 + /// `start_line` は読み始めのカーソル行である。 /// /// 目盛り `(p, t)` は「番号 p から次の目盛りまでの行が t に出た」という意味なので、 + /// 打つ番号はこの読みが**作る最初の行**でなければならない。`line = 0` は画面の + /// 上端なので、`before` が指すのは読み始めに画面の上端に有る行 — ずっと前の読みが + /// 書いた行 — である。そこへ打つと、すでに画面に出ていた最大「画面行数 - 1」行が + /// この読みの時刻になってしまう。既定の窓なら 46 行ほどで、エージェントが 20 分後に + /// 1 行出すだけで、前の答えの末尾 46 行が新しい時刻を持つ。この読みが作る行は + /// カーソルの居る所から始まるので、`before + start_line` へ打つ。 + /// /// 1 行も押し出していない読みには残すものが無い。それどころか、同じ行数の目盛りは /// 積まないので、次に本当に押し出した読みの枠を取ってしまう。プロンプトの /// 描き直しや打った字の echo がまさにそれで、`ls` の出力がその前の出力の時刻に /// なってしまう。 - pub fn mark_read(&mut self, before: u64, after: u64, at: Instant) { + /// + /// カーソルを足すぶん、打つ番号が前の目盛りより小さくなりうる(本画面の 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, at); + self.push(before + start_line.max(0) as u64, at); } /// いまの行数と時刻を積む。 @@ -186,6 +200,8 @@ pub struct Counting<'a> { cap: usize, /// 行ごとの道で数えた行数。 hooked: u64, + /// 始めた時点のカーソル行。この読みが作る行はここから始まる。 + start_line: i32, } impl<'a> Counting<'a> { @@ -193,15 +209,28 @@ impl<'a> Counting<'a> { // 代替画面の履歴行数は本画面のものではない。差の起点にはしない。 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, } } + /// 始めた時点のカーソル行。 + /// + /// この読みが作る行は、カーソルの居る所から始まる。`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 { @@ -627,7 +656,7 @@ mod tests { // 落ちて、出力が 20 分前の時刻になってしまう。探している行はたいていそれである。 let mut ticks = Ticks::new(); let first = Instant::now() + Duration::from_secs(1200); - ticks.mark_read(0, 76, first); + ticks.mark_read(0, 76, 0, first); assert_eq!(ticks.time_of(30), Some(first), "出力はその読みの時刻になる"); } @@ -644,6 +673,22 @@ mod tests { assert_eq!(ticks.time_of(30), Some(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), Some(t0), "1 つめの読みが画面に残した行"); + assert_eq!(ticks.time_of(100), Some(t1), "2 つめの読みが書き始めた行"); + assert_eq!(ticks.time_of(150), Some(t1), "2 つめの読みで出た行"); + } + #[test] fn 押し出さない読みは目盛りを取らない() { // 出力 → プロンプトの描き直し(押し出しなし)→ 10 分の間 → `ls` の出力。 @@ -653,9 +698,9 @@ mod tests { let prompt = t0 + Duration::from_secs(1); let t1 = t0 + Duration::from_secs(600); let mut ticks = Ticks::new(); - ticks.mark_read(0, 76, t0); - ticks.mark_read(76, 76, prompt); - ticks.mark_read(76, 176, t1); + 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), Some(t1), "`ls` の出力で出た行"); assert_eq!(ticks.time_of(30), Some(t0), "1 つめの出力で出た行"); } diff --git a/src/pty.rs b/src/pty.rs index 3a6288c..96d37db 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -402,14 +402,19 @@ 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 { 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; @@ -419,20 +424,27 @@ fn spawn_reader( out.push(UiEvent::Osc(id, ev)); } 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 読みにつき 1 回だけ残す。 // - // 目盛りへ入れるのは読む前の数である。読んだ後の数を入れると、 - // この読みで出た行の番号がどれも目盛りより大きくなり、一つ前の - // 目盛り(間が空いていれば何分も前の時刻)に落ちてしまう。 + // 目盛りへ入れるのは読む前の数に読み始めのカーソル行を足した番号である。 + // 読んだ後の数を入れると、この読みで出た行の番号がどれも目盛りより + // 大きくなり、一つ前の目盛り(間が空いていれば何分も前の時刻)に + // 落ちてしまう。読む前の数そのものだと、逆に 1 画面ぶん早く打つ。 // 1 行も押し出していない読みを渡すかどうかは `Ticks` が決める。 pushed.store(counted, Ordering::Relaxed); ticks .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .mark_read(before, counted, std::time::Instant::now()); + .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/scan.rs b/src/scan.rs index ecf65ba..661d8a6 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -409,7 +409,7 @@ mod tests { parser.advance(&mut term, play); let mut ticks = Ticks::new(); if let Some(at) = at { - ticks.mark_read(0, 1, at); + ticks.mark_read(0, 1, 0, at); } Target { id, @@ -639,7 +639,10 @@ mod tests { let t = target(1, &script, Some(t0)); // 本画面で押し出した行のぶん、目盛りを 2 つ持たせる。 let newest = t0 + Duration::from_secs(60); - t.ticks.lock().expect("目盛りの錠").mark_read(1, 2, newest); + 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(), From 831971f6818b040823e0dd176a43da652c0bb70f Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:20:42 +0900 Subject: [PATCH 23/30] fix: publish the pushed total under the term lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.rs | 5 +++-- src/pty.rs | 11 +++++++++-- src/scan.rs | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 172b106..9e20d55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2047,8 +2047,9 @@ impl App { let needle = scan::needle(&query); use alacritty_terminal::grid::Dimensions as _; // 錠は 1 度の短い読みだけで放す。`pushed` は走査と同じ読み方で、錠の中で読む。 - // 読み手は `term` を放してから `pushed` を書くので 1 読みぶんずれうるが、 - // そのずれは下の行の再確認で捕まえる。 + // 読み手も錠の中で書くので、グリッドと総数は食い違わない。 + // それでも下で行を再確認するのは、走査してから選ぶまでに行が書き換わる + // (その場で描き直す UI、`resize` の折り返し直し)ことへの備えである。 let landed = state.manager.selected().map(|session| { let term = session.term.lock(); let pushed = session.pushed.load(std::sync::atomic::Ordering::Relaxed); diff --git a/src/pty.rs b/src/pty.rs index 96d37db..90ab6f3 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -427,15 +427,22 @@ fn spawn_reader( 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 回だけ残す。 + // 時刻は、錠を手放してから 1 読みにつき 1 回だけ残す。 + // 読み手は `term` を持ったまま `ticks` を取らない(走査もその順を守る)。 // // 目盛りへ入れるのは読む前の数に読み始めのカーソル行を足した番号である。 // 読んだ後の数を入れると、この読みで出た行の番号がどれも目盛りより // 大きくなり、一つ前の目盛り(間が空いていれば何分も前の時刻)に // 落ちてしまう。読む前の数そのものだと、逆に 1 画面ぶん早く打つ。 // 1 行も押し出していない読みを渡すかどうかは `Ticks` が決める。 - pushed.store(counted, Ordering::Relaxed); ticks .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) diff --git a/src/scan.rs b/src/scan.rs index 661d8a6..c767530 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -250,6 +250,7 @@ fn scan_lines( 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 => { From 52fcd3e8a9940a83d1e79ddcc3344649305600f4 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:22:01 +0900 Subject: [PATCH 24/30] fix: hand a case-insensitive pattern from a hit to the screen search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.rs | 5 +++ src/search.rs | 109 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9e20d55..1c8aa68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2039,6 +2039,11 @@ impl App { 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; diff --git a/src/search.rs b/src/search.rs index 6bfb9f5..9deb98d 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 旗を立てなければ_smart_case_のままである() { + // `⌘F` 自身の振る舞いは変えない。大文字を含む語は区別する。 + 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 特殊文字を打ち消す() { From a32aedfcc753a6112f6729dab4f6d5b47394ad87 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:25:40 +0900 Subject: [PATCH 25/30] fix: keep the counting when a read clears the scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lines.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lines.rs b/src/lines.rs index 8eb7a95..bc35259 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -176,6 +176,8 @@ impl Default for Ticks { /// 範囲を絞った中のスクロール)を数えてしまうこともない。 /// 達していれば差が出ないので、行ごとの道で数えたほうを答えにする。行ごとに /// 数えるのは、改行・NEL・SU・行削除・画面消去(ED 2)・行末のタブである。 +/// 同じ読みがスクロールバックを捨てた(ED 3)ときも差が出ないので、そこでも +/// 行ごとの道を採る。 /// /// 上限に達してからは、数え漏らしと数え過ぎが残る。行ごとの道は画面の形しか /// 見ていないからである。 @@ -189,6 +191,14 @@ impl Default for Ticks { /// `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> { @@ -202,6 +212,12 @@ pub struct Counting<'a> { hooked: u64, /// 始めた時点のカーソル行。この読みが作る行はここから始まる。 start_line: i32, + /// この読みのあいだにスクロールバックが捨てられた(ED 3)。 + /// + /// 履歴行数の差がこの読みの押し出しを表さなくなる。始めた時点の履歴が 0 でも + /// (この読みが押し出してから消した)差は 0 になるので、`after < before` では + /// 捕まらない。捨てた瞬間に印を付けておく。 + dropped: bool, } impl<'a> Counting<'a> { @@ -218,6 +234,7 @@ impl<'a> Counting<'a> { cap, hooked: 0, start_line, + dropped: false, } } @@ -239,7 +256,13 @@ impl<'a> Counting<'a> { Some(before) if !self.alt() => { let after = self.history(); let delta = after.saturating_sub(before) as u64; - if after >= self.cap { + if self.dropped || after < before { + // スクロールバックが捨てられた(ED 3)か、ほかの理由で履歴が + // 縮んだ。どちらも差はこの読みの押し出しを表さないので、行ごとに + // 数えたほうを採る。消えた行はもう引かれないが、押し出した数を + // 捨てると、この読みより後の行の通し番号がそのぶん低く出てしまう。 + delta.max(self.hooked) + } else if after >= self.cap { // この 1 回で上限に届いた。届いたあとの押し出しは差に出ない。 // 1 回の読みは 64KB まで入るので、改行だけなら何千行にもなる。 delta.max(self.hooked) @@ -354,6 +377,11 @@ impl Handler for Counting<'_> { } else { 0 }; + // ED 3 は履歴を丸ごと捨てる(`Term` は履歴が有るときだけ捨てる)。 + // 捨てられたら、この読みの数え上げは差からは出せない。 + if matches!(mode, ClearMode::Saved) && self.history() > 0 { + self.dropped = true; + } self.counted(lines, |term| term.clear_screen(mode)); } @@ -539,6 +567,31 @@ mod tests { 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 のままである。 + 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 行削除で押し出された行も数える() { // 画面の先頭で 2 行消すと、画面の中身が 2 行ぶん上へ送られる。 From 003ce2dc7b3ebfa01638706b90988c3e0877fcbc Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:27:14 +0900 Subject: [PATCH 26/30] 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 --- src/lines.rs | 53 ++++++++++++++++++++++++++-------------------------- src/scan.rs | 44 ++++++++----------------------------------- 2 files changed, 35 insertions(+), 62 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index bc35259..4225dfb 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -137,15 +137,16 @@ impl Ticks { /// 目盛りを捨てたぶんの時刻は分からない。残っている最古の時刻を返す。 /// その行がそれより古いことは確かである。 /// 目盛りが 1 つも無ければ、本を開いた時刻を返す。 - pub fn time_of(&self, serial: i64) -> Option { + /// + /// どの行にも必ず時刻が付く。引けないことは無いので `Option` にしない。 + /// 包むと、呼ぶ側が「行でない」と「時刻が引けない」を取り違える。 + pub fn time_of(&self, serial: i64) -> Instant { let after = self .marks .partition_point(|&(pushed, _)| (pushed as i64) <= serial); - Some( - self.marks - .get(after.saturating_sub(1)) - .map_or(self.created_at, |&(_, at)| at), - ) + self.marks + .get(after.saturating_sub(1)) + .map_or(self.created_at, |&(_, at)| at) } /// いちばん新しい目盛りの時刻。 @@ -153,8 +154,8 @@ impl Ticks { /// 代替画面の行は通し番号が引けない(`pushed` が進まないので、付けた番号は /// 本画面の無関係な行を指す)。その行には、この本が最後に行を押し出した /// 時刻を代わりに付ける。1 行も押し出していなければ、本を開いた時刻になる。 - pub fn newest(&self) -> Option { - Some(self.marks.back().map_or(self.created_at, |&(_, at)| at)) + pub fn newest(&self) -> Instant { + self.marks.back().map_or(self.created_at, |&(_, at)| at) } } @@ -681,11 +682,11 @@ mod tests { ticks.push(10, t0); ticks.push(20, t1); ticks.push(30, t2); - assert_eq!(ticks.time_of(25), Some(t1)); - assert_eq!(ticks.time_of(30), Some(t2)); + assert_eq!(ticks.time_of(25), t1); + assert_eq!(ticks.time_of(30), t2); assert_eq!( ticks.time_of(5), - Some(t0), + t0, "一番古い目盛りより前なら、残っている最古の時刻を返す" ); } @@ -696,10 +697,10 @@ mod tests { let before = Instant::now(); let ticks = Ticks::new(); let after = Instant::now(); - let at = ticks.time_of(0).expect("作った時刻を返す"); + let at = ticks.time_of(0); assert!(at >= before && at <= after, "本を開いた時刻である"); - assert_eq!(ticks.time_of(-5), Some(at), "履歴側の行にも同じ時刻が付く"); - assert_eq!(ticks.newest(), Some(at), "最後の目盛りも作った時刻になる"); + assert_eq!(ticks.time_of(-5), at, "履歴側の行にも同じ時刻が付く"); + assert_eq!(ticks.newest(), at, "最後の目盛りも作った時刻になる"); } #[test] @@ -710,7 +711,7 @@ mod tests { 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), Some(first), "出力はその読みの時刻になる"); + assert_eq!(ticks.time_of(30), first, "出力はその読みの時刻になる"); } #[test] @@ -722,8 +723,8 @@ mod tests { let mut ticks = Ticks::new(); ticks.push(0, t0); ticks.push(76, t1); - assert_eq!(ticks.time_of(150), Some(t1), "2 つめの出力で出た行"); - assert_eq!(ticks.time_of(30), Some(t0), "1 つめの出力で出た行"); + assert_eq!(ticks.time_of(150), t1, "2 つめの出力で出た行"); + assert_eq!(ticks.time_of(30), t0, "1 つめの出力で出た行"); } #[test] @@ -737,9 +738,9 @@ mod tests { 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), Some(t0), "1 つめの読みが画面に残した行"); - assert_eq!(ticks.time_of(100), Some(t1), "2 つめの読みが書き始めた行"); - assert_eq!(ticks.time_of(150), Some(t1), "2 つめの読みで出た行"); + 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] @@ -754,8 +755,8 @@ mod tests { 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), Some(t1), "`ls` の出力で出た行"); - assert_eq!(ticks.time_of(30), Some(t0), "1 つめの出力で出た行"); + assert_eq!(ticks.time_of(150), t1, "`ls` の出力で出た行"); + assert_eq!(ticks.time_of(30), t0, "1 つめの出力で出た行"); } #[test] @@ -764,7 +765,7 @@ mod tests { let mut ticks = Ticks::new(); ticks.push(10, t0); ticks.push(10, t0 + Duration::from_secs(1)); - assert_eq!(ticks.time_of(10), Some(t0), "先に見た時刻を残す"); + assert_eq!(ticks.time_of(10), t0, "先に見た時刻を残す"); } #[test] @@ -773,11 +774,11 @@ mod tests { let mut ticks = Ticks::new(); ticks.push(10, t0); ticks.push(20, t0 + Duration::from_millis(500)); - assert_eq!(ticks.time_of(20), Some(t0), "1 秒経っていないので積まない"); + assert_eq!(ticks.time_of(20), t0, "1 秒経っていないので積まない"); ticks.push(30, t0 + Duration::from_secs(1)); assert_eq!( ticks.time_of(30), - Some(t0 + Duration::from_secs(1)), + t0 + Duration::from_secs(1), "1 秒経てば積む" ); } @@ -791,7 +792,7 @@ mod tests { } assert_eq!( ticks.time_of(0), - Some(t0 + Duration::from_secs(100)), + t0 + Duration::from_secs(100), "溢れた 100 件は捨て、残っている最古の時刻を返す" ); } diff --git a/src/scan.rs b/src/scan.rs index c767530..b76a8f4 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -104,7 +104,9 @@ pub struct Hit { pub kind: HitKind, /// 当たった行の通し番号。名前・題名・枝と、代替画面の行なら `None`。 pub serial: Option, - /// その行が現れた時刻。目盛りが残っていなければ `None`。 + /// その行が現れた時刻。行でない件(名前・題名・枝)だけが `None` である。 + /// + /// `Ticks` はどの行にも必ず時刻を答えるので、行の件が `None` になることは無い。 pub at: Option, /// 伏せ字を通したあとの行の文字列。 pub text: String, @@ -165,12 +167,10 @@ pub fn scan(targets: &[Target], query: &str, redactor: &Redactor, limits: Limits /// 一覧の並び。名前・題名・枝を先頭に置き、残りを新しい順に並べる。 /// /// 先頭に置くのは、それが時刻を持たないからではなく、行ではないからである。 -/// 時刻の引けない行は逆に後ろへ回す。分からない行を、分かっている新しい行の上に -/// 出さないためである。`Ticks` は本を開いた時刻を必ず答えるので、いま行が時刻なしで -/// 届くことはない。この分岐は守りとして置いてある。 +/// 時刻を見るのは行の件どうしだけで、そこには必ず時刻が付いている +/// (`Ticks::time_of` は答えを返さないことがない)。 fn newest_first(a: &Hit, b: &Hit) -> Ordering { let pinned = |h: &Hit| h.kind != HitKind::Line; - // 時刻は `None` が小さいので、新しい順に並べれば後ろへ回る。 pinned(b).cmp(&pinned(a)).then_with(|| b.at.cmp(&a.at)) } @@ -308,10 +308,11 @@ fn scan_lines( kind: HitKind::Line, serial, // 番号の引けない行には、この本が最後に押し出した時刻を付ける。 - at: match serial { + // 行には必ず時刻が付く。`None` になるのは行でない件だけである。 + at: Some(match serial { Some(serial) => ticks.time_of(serial), None => ticks.newest(), - }, + }), text, hit, }); @@ -460,35 +461,6 @@ mod tests { assert_eq!(hits[1].id, 1); } - /// 並べ替えだけを見たい当たり。 - fn hit(kind: HitKind, at: Option) -> Hit { - Hit { - id: 1, - kind, - serial: at.map(|_| 0), - at, - text: String::new(), - hit: 0..0, - } - } - - #[test] - fn 時刻の無い行は後ろへ回す() { - // 先頭に来るのは名前・題名・枝だけで、時刻の引けない行は後ろへ回る。 - // `Ticks` が作った時刻を必ず答えるので、`scan` を通って時刻の無い行が - // 出ることはない。並べ替えの守りを直に試す。 - let t0 = Instant::now(); - let mut hits = [ - hit(HitKind::Line, None), - hit(HitKind::Line, Some(t0)), - hit(HitKind::Name, None), - ]; - hits.sort_by(newest_first); - assert_eq!(hits[0].kind, HitKind::Name, "名前は先頭に残る"); - assert_eq!(hits[1].at, Some(t0), "時刻の分かる行が先"); - assert_eq!(hits[2].at, None, "時刻の引けない行は後ろ"); - } - #[test] fn 名前は新しい行より先に来る() { // 行の時刻がいくら新しくても、名前・題名・枝の件を押し下げない。 From 52cd6a2a4eb436c9463dd8356deb0b6e04a61e41 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:28:55 +0900 Subject: [PATCH 27/30] refactor: enter the cross-session search through one door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `⌘⇧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 --- src/main.rs | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1c8aa68..f9ed54c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1201,19 +1201,7 @@ impl App { state.search = None; state.find = Some(search::ScreenSearch::new()); } - Action::FindAcrossSessions => { - 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(); - } - } + Action::FindAcrossSessions => self.enter_across(), Action::SearchHistory => { state.picker = None; state.find = None; @@ -1599,12 +1587,9 @@ impl App { } SidebarHit::Close(i) => state.manager.close(i), SidebarHit::SearchBox => { - // 窓に入る(仕様 §9)。入っているなら、そのままにする。 + // 窓に入る(仕様 §9)。入っているなら、打った語を捨てない。 if state.across.is_none() { - state.find = None; - // 掴んでいた行は放す。結果の一覧に並べ替えは効かせない。 - state.mouse.sidebar_drag = None; - state.across = Some(CrossSearch::default()); + self.enter_across(); } // 並び順は変わらない。保存を予約しない。 return; @@ -2021,6 +2006,30 @@ 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` 件目を開く。そのセッションへ切り替え、行が残っていればそこへ飛ぶ。 /// /// 名前・題名・枝の件と、行が履歴から消えた件は、送り先が無いので切り替えて From 13651048a56c9d0155c98b889685df307dc7407e Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:29:57 +0900 Subject: [PATCH 28/30] docs: record the wrap hole, rename a test, reuse the scan's needle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 +++++ src/probe.rs | 4 +++- src/scan.rs | 10 +++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f7071b..42c9f9c 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ 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/src/probe.rs b/src/probe.rs index c34dd16..c139bb1 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -210,7 +210,9 @@ pub fn run(out_path: &str) { // 文字を取り違える。 let ids: Vec = state.manager.sessions().iter().map(|s| s.id).collect(); let query = "SES"; - let needle = query.to_lowercase(); + // 走査と同じ針の作り方をする。`str::to_lowercase` は語末シグマで + // `find_hit` の `char` ごとの小文字化と食い違う(`scan::needle`)。 + let needle = crate::scan::needle(query); let rows: &[(u32, crate::scan::HitKind, &str)] = &[ // 前後どちらも落とさないと入らない長い行。 ( diff --git a/src/scan.rs b/src/scan.rs index b76a8f4..203f9d9 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -328,6 +328,11 @@ fn scan_lines( /// /// 全角の後ろ半分は実体を持たないコマなので飛ばす(`session.rs` の `grid_text` と /// 同じ規則)。走査と、選んで飛ぶときの確かめが同じ読み方をするよう、ここに 1 つだけ置く。 +/// +/// `Flags::WRAPLINE` は見ないので、折り返しを跨ぐ語は当たらない。`⌘F` の渡す先 +/// (alacritty の `search_next`)はグリッドを流れとして歩くので見つける。穴として +/// 受け入れて README に書いた。繋いで読むには、当たったバイト位置をどの行へ戻すか +/// という別の工事が要る。 pub fn row_text( grid: &alacritty_terminal::grid::Grid, row: i32, @@ -526,8 +531,11 @@ mod tests { } #[test] - fn 全体の上限で止まる() { + 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 { From 0476e3a1d866dc663a9d5522c158f9d2e2322eba Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:53:15 +0900 Subject: [PATCH 29/30] test: cover the un-flagged history shrink, and fix two stale names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lines.rs | 25 ++++++++++++++++++++++++- src/search.rs | 4 ++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lines.rs b/src/lines.rs index 4225dfb..f68a7f0 100644 --- a/src/lines.rs +++ b/src/lines.rs @@ -585,6 +585,8 @@ mod tests { 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"); @@ -593,6 +595,23 @@ mod tests { 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 行ぶん上へ送られる。 @@ -717,7 +736,11 @@ mod tests { #[test] fn 間の空いた出力は前の出力の時刻にならない() { // 1 つめの出力で 76 行押し出し、10 分おいて 2 つめの出力で 100 行押し出した。 - // 目盛りに入れるのは読む前の数なので、2 つめで出た行は 2 つめの時刻になる。 + // 目盛りの境より後の行は後の時刻、前の行は前の時刻になること。 + // + // ここは `push` を直に叩くので、打つ番号は素の押し出し総数である。 + // 読みから打つときにどの番号を選ぶか(カーソル行を足す)は `mark_read` の + // 仕事で、それは `画面に残っていた行は次の読みの時刻にならない` が見ている。 let t0 = Instant::now(); let t1 = t0 + Duration::from_secs(600); let mut ticks = Ticks::new(); diff --git a/src/search.rs b/src/search.rs index 9deb98d..c6f781a 100644 --- a/src/search.rs +++ b/src/search.rs @@ -204,8 +204,8 @@ mod tests { } #[test] - fn 旗を立てなければ_smart_case_のままである() { - // `⌘F` 自身の振る舞いは変えない。大文字を含む語は区別する。 + fn 旗を立てなければ大文字を含む語は区別する() { + // `⌘F` 自身の振る舞いは変えない。`RegexSearch::new` の smart-case のままである。 let term = fed("the ses bounce handler\r\n"); let mut s = ScreenSearch::new(); s.query = "SES".into(); From 82896a660e385694de7a5357c4e696a5e0e59794 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 29 Oct 2026 01:58:38 +0900 Subject: [PATCH 30/30] docs: measure what the line counting costs on the read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- docs/performance.md | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index 44c214f..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 | @@ -635,6 +638,84 @@ RUST_LOG=info TERMIT_FRAME_LOG=1 cargo run --release 走査が走っているあいだの毎フレームの記録に、落ちたフレームが無いことを見る。 あわせて、検索中も各セッションの出力が止まらないことを目で確かめる。 +## 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) にまとめた。