From d758613315a587fc5528a89d24b2f83b1dbeece7 Mon Sep 17 00:00:00 2001 From: tkc Date: Fri, 30 Oct 2026 07:07:58 +0900 Subject: [PATCH] feat: scroll the left pane when the sessions outgrow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session list dropped every row that did not fit. With fourteen sessions open the last ones were simply not drawn, and a jump key could select a row nobody could see. The list now starts at a row offset and scrolls. Rows have different heights — the title line and the branch line are both optional — so the offset counts tree rows and the placement walks the measured heights: sidebar_rows counts what fits from an offset, sidebar_max_offset stops the wheel at the row that puts the last one on the bottom edge, and sidebar_follow raises the offset until the selected row is inside. The wheel over the left pane moves the list. It used to fall through to the selected terminal's scrollback, which is not what the pointer was on. When the cross-session hits are showing, it moves those instead. Following happens only when the selected row changes, not every frame: every frame would undo the wheel the moment the hand left it. The clamp to the last usable offset does run every frame, since closing a session lowers it. Two coordinate systems meet in the drag-to-reorder path and used to coincide. sidebar_boundary now returns the tree row rather than the position inside the window, and sidebar_boundary_y converts back. The jump label is taken from the tree row as well, so the key a row wears is still the key that selects it. Rows outside the window now reconcile shown_working too. Leaving them stale made wants_redraw ask for a full redraw on every byte a session nobody could see printed. TERMIT_PROBE_OVERLAY=many renders the scrolled list for the eye check. Co-Authored-By: Claude Opus 5 --- README.md | 6 + src/main.rs | 333 ++++++++++++++++++++++++++++++++++++++++++++++----- src/probe.rs | 18 +++ 3 files changed, 330 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ab8d5ba..4a59682 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,11 @@ and pass it through `printf '%b'`, the escape handling collides and the link comes out broken. Keep the colours as real bytes (`$'\033[33m'`) and print everything with `%s`. +**When the sessions outgrow the left pane.** The list shows as many rows as +fit and scrolls for the rest: the wheel over the left pane moves it, and +switching sessions brings the selected row back into view. A row always wears +the key that selects it, so the labels tell you where in the list you are. + **Reordering the left pane.** Drag a row and drop it where the line appears. A session with forks under it moves together with them, and a fork moves within its parent — the drop marker only appears where the row can actually land. The @@ -281,6 +286,7 @@ actually arrive as. | Drag in the terminal | Select text (`⌘C` copies) | | Double / triple click | Select word / line | | Wheel | Scroll | +| Wheel over the left pane | Scroll the session list | | Drag the pane border | Resize the left pane | When the program running in the terminal asks for mouse reporting (vim, htop, diff --git a/src/main.rs b/src/main.rs index f9ed54c..f0f4349 100644 --- a/src/main.rs +++ b/src/main.rs @@ -429,6 +429,16 @@ pub(crate) struct State { rename: Option, /// 左ペインの幅(pt)。境目を掴んで変えられる。 sidebar_width: f32, + /// 一覧の先頭に出している行(`Manager::tree_rows` の添字)。 + /// + /// セッションが増えると、入りきらない行が下で見切れる。車輪で送り、 + /// 選んだ行が窓の外へ出たときは追いかける。 + sidebar_offset: usize, + /// 送り位置を合わせた選択行。これが変わったときだけ合わせ直す。 + /// + /// 毎フレーム合わせると、車輪で送った位置をその場で戻してしまう。 + /// `shown_title` と同じ、描いた状態の控えである。 + sidebar_followed: Option, /// 画面の中の検索。 find: Option, /// 全セッションを横断する検索。開いていなければ `None`。 @@ -673,6 +683,8 @@ impl ApplicationHandler for App { picker: None, rename: None, sidebar_width: self.config.window.sidebar_width, + sidebar_offset: 0, + sidebar_followed: None, find: None, across: None, scan_generation: 0, @@ -1578,7 +1590,9 @@ impl App { let sl = sidebar_layout(state, &layout); if let Some(row) = sl.blocks.iter().position(|b| b.index == i) { state.mouse.sidebar_drag = Some(SidebarDrag { - from_row: row, + // 掴んだのは窓の中の位置。送っているぶんを足して + // 木の行に直す。 + from_row: sl.offset + row, start_y: state.mouse.y, to: None, moved: false, @@ -1765,6 +1779,12 @@ impl App { return; } let layout = layout_of(&self.config, state); + // 左ペインの上で回したら、一覧を送る。ここで返さないと、下の経路が + // 選んでいるセッションのスクロールバックを遡らせる。 + if state.mouse.x < layout.sidebar_px { + scroll_sidebar(state, &layout, lines); + return; + } let (col, row, _) = mouse_cell(state); let cell = terminal_cell(&layout, col, row); let Some(session) = state.manager.selected() else { @@ -2676,6 +2696,11 @@ struct SidebarLayout { rect_x: f32, rect_w: f32, blocks: Vec, + /// 一覧の先頭に出している行。`blocks` の添字に足すと木の行になる。 + /// + /// 結果の一覧では 0 のままにする。`Block::index` が当たりの絶対の + /// 位置を持っており、送った位置はそちらに入っている。 + offset: usize, /// 区切り線の位置(画素)。 sep_y: f32, } @@ -2686,6 +2711,154 @@ fn sidebar_limit(state: &State, layout: &Layout) -> f32 { height_px - 120.0 * state.renderer.scale() } +/// 各行の矩形の高さ(画素)。渡された行と同じ並びで返す。 +/// +/// 高さは段の数で変わる(題名や枝があれば高い)。送り位置を決める側と +/// 割り付ける側で同じ値を使うため、ここで 1 度だけ出す。 +/// 行の並びは呼ぶ側から渡す。`tree_rows` は毎回組み直すので、 +/// 1 フレームのうちに何度も作らせない。 +fn sidebar_row_heights(state: &State, rows: &[crate::session::TreeRow]) -> Vec { + let sc = state.renderer.scale(); + let title = state.renderer.line_height_px(sidebar::SIZE_TITLE); + let sub = state.renderer.line_height_px(sidebar::SIZE_SUB); + let branch = state.renderer.line_height_px(sidebar::SIZE_BRANCH); + rows.iter() + .map(|tree| { + // `tree_rows` はセッションを数え上げて作るので、添字は必ずある。 + let s = &state.manager.sessions()[tree.index]; + let mut content = title; + if s.window_title.is_some() { + content += sub; + } + if s.branch.is_some() { + content += branch; + } + (sidebar::PAD_TOP + sidebar::PAD_BOTTOM) * sc + content + }) + .collect() +} + +/// `offset` から `limit` までに置ける行数。 +/// +/// 高さが行ごとに違うので、件数では出せない。割り付け(`sidebar_layout`)と +/// 同じ順で足していく。 +fn sidebar_rows(heights: &[f32], offset: usize, top: f32, limit: f32, gap: f32) -> usize { + let mut y = top; + let mut rows = 0; + for h in heights.iter().skip(offset) { + if y + h > limit { + break; + } + rows += 1; + y += h + gap; + } + rows +} + +/// 送れる上限。最後の行が下端に来る位置より先へは送らない。 +/// +/// 送りすぎて空白だけを出さないための歯止めである。末尾から積み上げて、 +/// まだ入る限り先頭へ戻す。 +fn sidebar_max_offset(heights: &[f32], top: f32, limit: f32, gap: f32) -> usize { + let mut y = top; + let mut offset = heights.len(); + for (i, h) in heights.iter().enumerate().rev() { + if y + h > limit { + break; + } + y += h + gap; + offset = i; + } + // 送り位置は行の範囲に収める。`len` を返すと、どこへ送っても空になる。 + offset.min(heights.len().saturating_sub(1)) +} + +/// 選んだ行が窓の中に入るよう、送り位置を出す。 +/// +/// 上に隠れていればその行へ戻す。下に隠れていれば、入るまで 1 行ずつ送る。 +/// 行の高さが違うので、引き算では出せない。 +fn sidebar_follow( + heights: &[f32], + offset: usize, + selected: usize, + top: f32, + limit: f32, + gap: f32, +) -> usize { + if selected < offset { + return selected; + } + let mut offset = offset; + while selected >= offset + sidebar_rows(heights, offset, top, limit, gap) { + // 1 行も入らない高さでは、送っても入らない。選んだ行で止める。 + if offset >= selected { + break; + } + offset += 1; + } + offset +} + +/// 車輪の行数を送り位置に足す。 +/// +/// 上へ回すと `lines` は正で、先頭(古い添字)へ戻る。 +fn wheel_offset(offset: usize, lines: i32, max: usize) -> usize { + if lines > 0 { + offset.saturating_sub(lines as usize) + } else { + (offset + lines.unsigned_abs() as usize).min(max) + } +} + +/// 送り位置を、いまの選択と高さに合わせ直す。 +/// +/// 選択行が変わったときだけ追いかける。毎フレーム追いかけると、車輪で +/// 送った位置をその場で戻してしまい、車輪が効かなくなる。 +/// 上限の丸めは毎フレームかける。セッションを閉じれば上限は下がる。 +fn settle_sidebar_offset(state: &mut State, layout: &Layout) { + let sc = state.renderer.scale(); + let top = sidebar::HEADER * sc; + let gap = sidebar::GAP * sc; + let limit = sidebar_limit(state, layout); + let rows = state.manager.tree_rows(); + let heights = sidebar_row_heights(state, &rows); + let selected = state.manager.selected_index(); + let row = rows.iter().position(|r| r.index == selected); + if state.sidebar_followed != row { + state.sidebar_followed = row; + if let Some(row) = row { + state.sidebar_offset = + sidebar_follow(&heights, state.sidebar_offset, row, top, limit, gap); + } + } + state.sidebar_offset = state + .sidebar_offset + .min(sidebar_max_offset(&heights, top, limit, gap)); +} + +/// 左ペインの一覧を車輪で送る。いま出している側の一覧だけを動かす。 +fn scroll_sidebar(state: &mut State, layout: &Layout, lines: i32) { + if showing_hits(state.across.as_ref()) { + // 窓に入る件数は割り付けと同じ関数で出す。 + let rows = across_window(state, layout); + if let Some(across) = &mut state.across { + let max = across.hits.len().saturating_sub(rows); + across.offset = wheel_offset(across.offset, lines, max); + } + return; + } + let sc = state.renderer.scale(); + let rows = state.manager.tree_rows(); + let heights = sidebar_row_heights(state, &rows); + let max = sidebar_max_offset( + &heights, + sidebar::HEADER * sc, + sidebar_limit(state, layout), + sidebar::GAP * sc, + ); + state.sidebar_offset = wheel_offset(state.sidebar_offset, lines, max); +} + /// セッションの一覧を、結果の一覧に差し替えるか。 /// /// 語が空、または空白だけなら元の一覧へ戻る(仕様 §6)。 @@ -2778,6 +2951,7 @@ fn sidebar_layout(state: &State, layout: &Layout) -> SidebarLayout { let limit = sidebar_limit(state, layout); let mut blocks = Vec::new(); + let mut offset = 0; let mut y = sidebar::HEADER * sc; if showing_hits(state.across.as_ref()) { // 打っているあいだ、一覧は結果の一覧に差し替わる(仕様 §6)。 @@ -2793,18 +2967,15 @@ fn sidebar_layout(state: &State, layout: &Layout) -> SidebarLayout { + sidebar::GAP * sc; } } 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; + // 入りきらない行は下で見切れる。`offset` から出し、残りは車輪で送る。 + let rows = state.manager.tree_rows(); + let heights = sidebar_row_heights(state, &rows); + offset = + state + .sidebar_offset + .min(sidebar_max_offset(&heights, y, limit, sidebar::GAP * sc)); + for (row, tree) in rows.iter().enumerate().skip(offset) { + let height = heights[row]; if y + height > limit { break; } @@ -2823,24 +2994,31 @@ fn sidebar_layout(state: &State, layout: &Layout) -> SidebarLayout { rect_x, rect_w, blocks, + offset, sep_y, } } -/// 縦の位置が、行と行のどの境目を指しているか。0 は先頭、行数は末尾。 +/// 縦の位置が、行と行のどの境目を指しているか。 +/// +/// 返すのは木の行の境目(`Manager::drop_row` と同じ単位)で、窓に出ている +/// 位置ではない。送っているあいだ、この 2 つは `offset` だけずれる。 +/// 混ぜると、掴んだ行が送った数だけ離れた位置へ落ちる。 fn sidebar_boundary(sl: &SidebarLayout, py: f32) -> usize { for (i, b) in sl.blocks.iter().enumerate() { if py < b.top + b.height / 2.0 { - return i; + return sl.offset + i; } } - sl.blocks.len() + sl.offset + sl.blocks.len() } -/// 境目を描く高さ。 +/// 境目を描く高さ。`at` は木の行の境目。 fn sidebar_boundary_y(sl: &SidebarLayout, at: usize, sc: f32) -> f32 { let half = sidebar::GAP * sc / 2.0; - match sl.blocks.get(at) { + // 窓より上の境目は先頭の行の上端、下の境目は末尾の行の下端に寄せる。 + // 窓の外に線を引いても見えない。 + match sl.blocks.get(at.saturating_sub(sl.offset)) { Some(b) => b.top - half, None => match sl.blocks.last() { Some(b) => b.top + b.height + half, @@ -3215,6 +3393,8 @@ fn draw_across_hits(state: &mut State, sl: &SidebarLayout, theme: &Theme) { pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { let sc = state.renderer.scale(); + // 割り付ける前に送り位置を決める。選択が動いていれば追いかける。 + settle_sidebar_offset(state, layout); let sl = sidebar_layout(state, layout); let height_px = layout.rows as f32 * state.renderer.cell().height; state @@ -3253,8 +3433,6 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { } let selected = state.manager.selected_index(); - // 描いた「動いている」状態は覚えておく。変わるときだけ描き直す。 - let mut shown: Vec<(usize, bool, bool)> = Vec::with_capacity(sl.blocks.len()); for (n, b) in sl.blocks.iter().enumerate() { let s = &state.manager.sessions()[b.index]; let running = s.is_running(); @@ -3270,7 +3448,6 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { }; let working = s.is_working(); let blocked = s.blocked; - shown.push((b.index, working, blocked)); let hovered = state.mouse.x < sl.width && state.mouse.y >= b.top && state.mouse.y < b.top + b.height; @@ -3290,7 +3467,9 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { // 右端に並べる印の左端。名前はここまでで切る。 let right = sl.rect_x + sl.rect_w; // 印は行ごとに幅が違う。実際に出すものではかる。 - let hint = input::index_label(n); + // 札は木の行で振る。窓の中の位置で振ると、送ったとき + // `⌘1` が別の行に付き、押すと窓の外の行へ飛ぶ。 + let hint = input::index_label(sl.offset + n); let hint_w = match &hint { Some(h) => state .renderer @@ -3417,11 +3596,13 @@ pub(crate) fn draw_sidebar(state: &mut State, layout: &Layout, theme: &Theme) { } } - for (i, working, blocked) in shown { - if let Some(s) = state.manager.sessions_mut().get_mut(i) { - s.shown_working = working; - s.shown_blocked = blocked; - } + // 描いた「動いている」状態は、窓の外の行も含めて合わせる。 + // 外の行を残すと、見えていないセッションが字を出すたびに + // `wants_redraw` が窓ぜんぶの描き直しを頼み続ける。 + for s in state.manager.sessions_mut() { + let working = s.is_working(); + s.shown_working = working; + s.shown_blocked = s.blocked; } // 掴んでいる行の落とし先。 @@ -4683,6 +4864,104 @@ mod search_view_tests { // 高さの無い件を数え続けない。これは描画の経路にある。 assert_eq!(across_rows(0.0, 1000.0, 0.0, 0.0), 0); } + + #[test] + fn 送った位置から置ける行数を数える() { + // 高さの違う 3 行。帯の下(34)から、あいだ 4 画素で置く。 + let h = [54.0, 71.0, 54.0]; + // 54 + 4 + 71 で 129。34 から置いて 163 がちょうど 2 行。 + assert_eq!(sidebar_rows(&h, 0, 34.0, 163.0, 4.0), 2); + assert_eq!( + sidebar_rows(&h, 0, 34.0, 162.0, 4.0), + 1, + "2 行目に 1 画素足りない" + ); + // 送れば、先頭の行が落ちた先から数える。 + assert_eq!(sidebar_rows(&h, 1, 34.0, 163.0, 4.0), 2); + assert_eq!( + sidebar_rows(&h, 3, 34.0, 1000.0, 4.0), + 0, + "末尾より先は 0 行" + ); + } + + #[test] + fn 最後の行が下端に来る位置より先へは送らない() { + let h = [54.0, 71.0, 54.0]; + // 3 行とも入るなら送らない。 + assert_eq!(sidebar_max_offset(&h, 34.0, 1000.0, 4.0), 0); + // 末尾の 2 行(71 + 4 + 54)が入る高さ。 + assert_eq!(sidebar_max_offset(&h, 34.0, 163.0, 4.0), 1); + // 末尾の 1 行しか入らない高さ。 + assert_eq!(sidebar_max_offset(&h, 34.0, 162.0, 4.0), 2); + // 1 行も入らない高さでも、行の範囲は出ない。 + assert_eq!(sidebar_max_offset(&h, 34.0, 34.0, 4.0), 2); + } + + #[test] + fn 選んだ行が下に隠れていれば送り位置を進める() { + let h = [54.0, 71.0, 54.0]; + // 2 行しか入らない高さで、3 行目を選ぶ。 + assert_eq!(sidebar_follow(&h, 0, 2, 34.0, 163.0, 4.0), 1); + // 入っているあいだは動かさない。車輪で送った位置を奪わない。 + assert_eq!(sidebar_follow(&h, 0, 1, 34.0, 163.0, 4.0), 0); + } + + #[test] + fn 選んだ行が上に隠れていればその行まで戻す() { + let h = [54.0, 71.0, 54.0]; + assert_eq!(sidebar_follow(&h, 2, 0, 34.0, 163.0, 4.0), 0); + } + + #[test] + fn 一行も入らない高さでも送り続けない() { + // ここは描画の経路にある。入らない高さで回り続けてはならない。 + let h = [54.0, 71.0, 54.0]; + assert_eq!(sidebar_follow(&h, 0, 2, 34.0, 34.0, 4.0), 2); + } + + #[test] + fn 車輪は上で戻し下で進める() { + // 上へ回すと `lines` は正。既存の経路と同じ向きである。 + assert_eq!(wheel_offset(3, 2, 10), 1); + assert_eq!(wheel_offset(1, 3, 10), 0, "先頭より前へは行かない"); + assert_eq!(wheel_offset(3, -2, 10), 5); + assert_eq!(wheel_offset(9, -5, 10), 10, "上限で止まる"); + } + + #[test] + fn 送った一覧でも境目は木の行で返る() { + // 窓の中の位置と木の行を混ぜると、掴んだ行が送った数だけ + // 離れた位置へ落ちる。 + let sl = SidebarLayout { + width: 200.0, + rect_x: 6.0, + rect_w: 186.0, + blocks: vec![ + Block { + index: 7, + depth: 0, + top: 34.0, + height: 50.0, + }, + Block { + index: 8, + depth: 0, + top: 88.0, + height: 50.0, + }, + ], + offset: 5, + sep_y: 150.0, + }; + // 1 本目の上半分はその行の前の境目、下半分は次の境目。 + assert_eq!(sidebar_boundary(&sl, 40.0), 5); + assert_eq!(sidebar_boundary(&sl, 80.0), 6); + // 窓の末尾より下は、出ている最後の行の後ろ。 + assert_eq!(sidebar_boundary(&sl, 300.0), 7); + // 描く高さは窓の中へ戻す。 + assert_eq!(sidebar_boundary_y(&sl, 6, 1.0), 86.0); + } } #[cfg(test)] diff --git a/src/probe.rs b/src/probe.rs index c139bb1..5763aed 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -115,6 +115,8 @@ pub fn run(out_path: &str) { picker: None, rename: None, sidebar_width: config.window.sidebar_width, + sidebar_offset: 0, + sidebar_followed: None, find: None, across: None, scan_generation: 0, @@ -259,6 +261,22 @@ pub fn run(out_path: &str) { // 切れ方(末尾が残ること)と、語が窓いっぱいでも幅が残ることを見る。 state.preedit = "けんさくごをながくへんかんしているところ".into(); } + Ok("many") => { + // 一覧に入りきらない数まで増やし、送った一覧を描く。 + // 選ぶのは末尾の行で、送り位置は `draw_sidebar` が追いかける。 + let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + for _ in 0..18 { + if state + .manager + .spawn_new(&config, HOST_PROFILE, &cwd) + .is_err() + { + break; + } + } + let last = state.manager.sessions().len().saturating_sub(1); + state.manager.select(last); + } Ok("preedit") => { state.preedit = "あっだ".into(); }