feat(tui2): transcript scrollbar (auto-hide + drag) (#8728)
## Summary - Add a transcript scrollbar in `tui2` using `tui-scrollbar`. - Reserve 2 columns on the right (1 empty gap + 1 scrollbar track) and plumb the reduced width through wrapping/selection/copy so rendering and interactions match. - Auto-hide the scrollbar when the transcript is pinned to the bottom (columns remain reserved). - Add mouse click/drag support for the scrollbar, with pointer-capture so drags don’t fall through into transcript selection. - Skip scrollbar hit-testing when auto-hidden to avoid an invisible interactive region. ## Notes - Styling is theme-aware: in light themes the thumb is darker than the track; in dark themes it reads as an “indented” element without going full-white. - Pre-Ratatui 0.30 (ratatui-core split) requires a small scratch-buffer bridge; this should simplify once we move to Ratatui 0.30. ## Testing - `just fmt` - `just fix -p codex-tui2 --allow-no-vcs` - `cargo test -p codex-tui2`
This commit is contained in:
parent
468ee8a75c
commit
8f10d3bf05
8 changed files with 921 additions and 9 deletions
89
codex-rs/Cargo.lock
generated
89
codex-rs/Cargo.lock
generated
|
|
@ -1820,6 +1820,7 @@ dependencies = [
|
|||
"pulldown-cmark",
|
||||
"rand 0.9.2",
|
||||
"ratatui",
|
||||
"ratatui-core",
|
||||
"ratatui-macros",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
|
|
@ -1841,6 +1842,7 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-highlight",
|
||||
"tui-scrollbar",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
|
|
@ -2005,6 +2007,20 @@ dependencies = [
|
|||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -2583,6 +2599,15 @@ version = "0.3.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
|
|
@ -3909,6 +3934,16 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.0",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
|
|
@ -4054,6 +4089,12 @@ version = "0.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "local-waker"
|
||||
version = "0.1.4"
|
||||
|
|
@ -5393,7 +5434,7 @@ source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#
|
|||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"compact_str 0.8.1",
|
||||
"crossterm",
|
||||
"indoc",
|
||||
"instability",
|
||||
|
|
@ -5402,7 +5443,27 @@ dependencies = [
|
|||
"paste",
|
||||
"strum 0.26.3",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-truncate 1.1.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"compact_str 0.9.0",
|
||||
"hashbrown 0.16.0",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"kasuari",
|
||||
"lru 0.16.2",
|
||||
"strum 0.27.2",
|
||||
"thiserror 2.0.17",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate 2.0.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
|
|
@ -6569,6 +6630,9 @@ name = "strum"
|
|||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros 0.27.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
|
|
@ -7415,6 +7479,16 @@ dependencies = [
|
|||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tui-scrollbar"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42613099915b2e30e9f144670666e858e2538366f77742e1cf1c2f230efcacd"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
|
|
@ -7482,6 +7556,17 @@ dependencies = [
|
|||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ pretty_assertions = "1.4.1"
|
|||
pulldown-cmark = "0.10"
|
||||
rand = "0.9"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-core = "0.1.0"
|
||||
ratatui-macros = "0.6.0"
|
||||
regex = "1.12.2"
|
||||
regex-lite = "0.1.8"
|
||||
|
|
@ -219,6 +220,7 @@ tree-sitter = "0.25.10"
|
|||
tree-sitter-bash = "0.25"
|
||||
tree-sitter-highlight = "0.25.10"
|
||||
ts-rs = "11"
|
||||
tui-scrollbar = "0.2.1"
|
||||
uds_windows = "1.1.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2"
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ ratatui = { workspace = true, features = [
|
|||
"unstable-rendered-line-info",
|
||||
"unstable-widget-ref",
|
||||
] }
|
||||
ratatui-core = { workspace = true }
|
||||
ratatui-macros = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
|
@ -73,6 +74,7 @@ strum_macros = { workspace = true }
|
|||
supports-color = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
textwrap = { workspace = true }
|
||||
tui-scrollbar = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ use crate::transcript_copy_action::TranscriptCopyAction;
|
|||
use crate::transcript_copy_action::TranscriptCopyFeedback;
|
||||
use crate::transcript_copy_ui::TranscriptCopyUi;
|
||||
use crate::transcript_multi_click::TranscriptMultiClick;
|
||||
use crate::transcript_scrollbar::render_transcript_scrollbar_if_active;
|
||||
use crate::transcript_scrollbar::split_transcript_area;
|
||||
use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent;
|
||||
use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling;
|
||||
use crate::transcript_scrollbar_ui::TranscriptScrollbarUi;
|
||||
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
||||
use crate::transcript_selection::TranscriptSelection;
|
||||
use crate::transcript_selection::TranscriptSelectionPoint;
|
||||
|
|
@ -337,6 +342,7 @@ pub(crate) struct App {
|
|||
transcript_total_lines: usize,
|
||||
transcript_copy_ui: TranscriptCopyUi,
|
||||
transcript_copy_action: TranscriptCopyAction,
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi,
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
|
|
@ -503,6 +509,7 @@ impl App {
|
|||
transcript_total_lines: 0,
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
|
||||
transcript_copy_action: TranscriptCopyAction::default(),
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
|
@ -708,18 +715,19 @@ impl App {
|
|||
return area.y;
|
||||
}
|
||||
|
||||
let transcript_area = Rect {
|
||||
let transcript_full_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: max_transcript_height,
|
||||
};
|
||||
let (transcript_area, _) = split_transcript_area(transcript_full_area);
|
||||
|
||||
self.transcript_view_cache
|
||||
.ensure_wrapped(cells, transcript_area.width);
|
||||
let total_lines = self.transcript_view_cache.lines().len();
|
||||
if total_lines == 0 {
|
||||
Clear.render_ref(transcript_area, frame.buffer);
|
||||
Clear.render_ref(transcript_full_area, frame.buffer);
|
||||
self.transcript_scroll = TranscriptScroll::default();
|
||||
self.transcript_view_top = 0;
|
||||
self.transcript_total_lines = 0;
|
||||
|
|
@ -760,12 +768,14 @@ impl App {
|
|||
);
|
||||
}
|
||||
|
||||
let transcript_area = Rect {
|
||||
let transcript_full_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: transcript_visible_height,
|
||||
};
|
||||
let (transcript_area, transcript_scrollbar_area) =
|
||||
split_transcript_area(transcript_full_area);
|
||||
|
||||
// Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply
|
||||
// copy already-rendered `Cell`s instead of re-running grapheme segmentation.
|
||||
|
|
@ -806,6 +816,13 @@ impl App {
|
|||
} else {
|
||||
self.transcript_copy_ui.clear_affordance();
|
||||
}
|
||||
render_transcript_scrollbar_if_active(
|
||||
frame.buffer,
|
||||
transcript_scrollbar_area,
|
||||
total_lines,
|
||||
max_visible,
|
||||
top_offset,
|
||||
);
|
||||
chat_top
|
||||
}
|
||||
|
||||
|
|
@ -854,21 +871,45 @@ impl App {
|
|||
return;
|
||||
}
|
||||
|
||||
let transcript_area = Rect {
|
||||
let transcript_full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: transcript_height,
|
||||
};
|
||||
let (transcript_area, transcript_scrollbar_area) =
|
||||
split_transcript_area(transcript_full_area);
|
||||
let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
|
||||
let max_x = transcript_area.right().saturating_sub(1);
|
||||
|
||||
if matches!(
|
||||
self.transcript_scrollbar_ui
|
||||
.handle_mouse_event(TranscriptScrollbarMouseEvent {
|
||||
tui,
|
||||
mouse_event,
|
||||
transcript_area,
|
||||
scrollbar_area: transcript_scrollbar_area,
|
||||
transcript_cells: &self.transcript_cells,
|
||||
transcript_view_cache: &mut self.transcript_view_cache,
|
||||
transcript_scroll: &mut self.transcript_scroll,
|
||||
transcript_view_top: &mut self.transcript_view_top,
|
||||
transcript_total_lines: &mut self.transcript_total_lines,
|
||||
mouse_scroll_state: &mut self.scroll_state,
|
||||
}),
|
||||
TranscriptScrollbarMouseHandling::Handled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Treat the transcript as the only interactive region for transcript selection.
|
||||
//
|
||||
// This prevents clicks in the composer/footer from starting or extending a transcript
|
||||
// selection, while still allowing a left-click outside the transcript to clear an
|
||||
// existing highlight.
|
||||
if mouse_event.row < transcript_area.y || mouse_event.row >= transcript_area.bottom() {
|
||||
if !self.transcript_scrollbar_ui.pointer_capture_active()
|
||||
&& (mouse_event.row < transcript_full_area.y
|
||||
|| mouse_event.row >= transcript_full_area.bottom())
|
||||
{
|
||||
if matches!(
|
||||
mouse_event.kind,
|
||||
MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||||
|
|
@ -1082,7 +1123,15 @@ impl App {
|
|||
return None;
|
||||
}
|
||||
|
||||
Some((transcript_height as usize, width))
|
||||
let transcript_full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: transcript_height,
|
||||
};
|
||||
let (transcript_area, _) = split_transcript_area(transcript_full_area);
|
||||
|
||||
Some((transcript_height as usize, transcript_area.width))
|
||||
}
|
||||
|
||||
/// Scroll the transcript by a number of visual lines.
|
||||
|
|
@ -2084,6 +2133,7 @@ mod tests {
|
|||
CopySelectionShortcut::CtrlShiftC,
|
||||
),
|
||||
transcript_copy_action: TranscriptCopyAction::default(),
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
|
@ -2136,6 +2186,7 @@ mod tests {
|
|||
CopySelectionShortcut::CtrlShiftC,
|
||||
),
|
||||
transcript_copy_action: TranscriptCopyAction::default(),
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ mod transcript_copy_action;
|
|||
mod transcript_copy_ui;
|
||||
mod transcript_multi_click;
|
||||
mod transcript_render;
|
||||
mod transcript_scrollbar;
|
||||
mod transcript_scrollbar_ui;
|
||||
mod transcript_selection;
|
||||
mod transcript_view_cache;
|
||||
mod tui;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ use std::time::Duration;
|
|||
use std::time::Instant;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::transcript_scrollbar::split_transcript_area;
|
||||
use crate::transcript_selection::TranscriptSelection;
|
||||
use crate::tui;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
/// User-visible feedback shown briefly after a copy attempt.
|
||||
///
|
||||
|
|
@ -162,10 +164,18 @@ pub(crate) fn copy_transcript_selection(
|
|||
return CopySelectionOutcome::NoSelection;
|
||||
}
|
||||
|
||||
let transcript_full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: transcript_height,
|
||||
};
|
||||
let (transcript_area, _) = split_transcript_area(transcript_full_area);
|
||||
|
||||
let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
|
||||
transcript_cells,
|
||||
transcript_selection,
|
||||
width,
|
||||
transcript_area.width,
|
||||
) else {
|
||||
return CopySelectionOutcome::NoSelection;
|
||||
};
|
||||
|
|
|
|||
535
codex-rs/tui2/src/transcript_scrollbar.rs
Normal file
535
codex-rs/tui2/src/transcript_scrollbar.rs
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
//! Transcript scrollbar rendering.
|
||||
//!
|
||||
//! The transcript in `codex-tui2` is rendered as a flattened list of wrapped visual lines. The
|
||||
//! viewport is tracked as a top-row offset (`transcript_view_top`) into that flattened list (see
|
||||
//! `tui/scrolling.rs` and `tui_viewport_and_history.md`).
|
||||
//!
|
||||
//! This module adds a scrollbar to that viewport using the `tui-scrollbar` widget, but does so in
|
||||
//! a way that keeps the transcript hot path simple and avoids visual layout jank.
|
||||
//!
|
||||
//! # Layout and invariants
|
||||
//!
|
||||
//! The transcript area is split into:
|
||||
//!
|
||||
//! - `content_area`: where transcript text is rendered
|
||||
//! - `scrollbar_area`: a 1-column region used to render the scrollbar
|
||||
//!
|
||||
//! Additionally, we reserve a 1-column *gap* between content and scrollbar. This produces a
|
||||
//! slightly more stable/intentional look (the scrollbar reads as an affordance, not part of the
|
||||
//! transcript content) and avoids accidental overlap with selection/copy UI.
|
||||
//!
|
||||
//! Important invariant: **any code that computes transcript wrapping, scrolling, selection, or
|
||||
//! copy must use the same width as on-screen transcript rendering**. In practice that means:
|
||||
//!
|
||||
//! - Use [`split_transcript_area`] and pass `content_area.width` into anything that depends on
|
||||
//! transcript width (wrapping, scroll deltas, selection reconstruction for copy, etc.).
|
||||
//! - Do not mix `terminal.width` and `content_area.width` for transcript operations; doing so
|
||||
//! causes off-by-one/off-by-two behaviors where the selection highlights and copied text do not
|
||||
//! match what the user sees.
|
||||
//!
|
||||
//! `App` follows this rule by deriving `content_area.width` anywhere it needs transcript width.
|
||||
//!
|
||||
//! # When the scrollbar is shown
|
||||
//!
|
||||
//! The scrollbar is only drawn when the transcript is *not* pinned to the bottom:
|
||||
//!
|
||||
//! - `offset < max_offset` → draw scrollbar
|
||||
//! - `offset == max_offset` → keep the column reserved but blank
|
||||
//!
|
||||
//! This keeps the UI clean during normal operation (where the viewport follows streaming output),
|
||||
//! while still providing a clear affordance when the user is actively reading scrollback.
|
||||
//!
|
||||
//! # Styling and theme heuristics
|
||||
//!
|
||||
//! `tui-scrollbar` 0.2.1 changed defaults (no arrows, space track + dark background). We keep
|
||||
//! default glyphs/arrows so the widget controls its own shape, but override track/thumb colors so
|
||||
//! it matches `codex-tui2`’s existing "user prompt block" styling:
|
||||
//!
|
||||
//! - The track background is a small blend toward the terminal foreground so it looks like a
|
||||
//! subtle indent against the terminal background.
|
||||
//! - The thumb foreground is a stronger blend so it reads as the active element.
|
||||
//! - In light themes (terminal background is light), the thumb is intentionally a *darker* shade
|
||||
//! than the track so it reads as an inset element.
|
||||
//!
|
||||
//! We derive these colors from the terminal’s default foreground/background (when available via
|
||||
//! `terminal_palette`). When defaults are unknown (tests / unsupported terminals), we fall back to
|
||||
//! ANSI colors so the scrollbar remains visible.
|
||||
//!
|
||||
//! # Pointer interaction (mouse click/drag)
|
||||
//!
|
||||
//! `tui-scrollbar` includes an interaction helper that translates pointer events into
|
||||
//! `ScrollCommand::SetOffset(...)` updates, including "grab offset" handling so dragging keeps the
|
||||
//! pointer anchored within the thumb.
|
||||
//!
|
||||
//! `codex-tui2` uses that helper rather than reimplementing scrollbar hit testing and drag math.
|
||||
//! The app owns the actual transcript scroll state (anchors in `tui/scrolling.rs`), so we only use
|
||||
//! `tui-scrollbar` to decide *which* offset the user requested. `App` then converts that raw
|
||||
//! `offset` back into a stable [`TranscriptScroll`] anchor.
|
||||
//!
|
||||
//! Note: we use `tui-scrollbar`’s backend-agnostic [`ScrollEvent`] types instead of its optional
|
||||
//! `crossterm` adapter, because the workspace uses a patched `crossterm` and we want to avoid
|
||||
//! pulling in multiple `crossterm` versions (which would make `MouseEvent` types incompatible).
|
||||
//!
|
||||
//! Because the scrollbar is visually hidden while pinned-to-bottom, `App` also keeps a tiny
|
||||
//! "pointer capture" bool so a drag that reaches the bottom doesn't accidentally turn into a text
|
||||
//! selection once the scrollbar disappears.
|
||||
//!
|
||||
//! # `ratatui` vs `ratatui-core`
|
||||
//!
|
||||
//! `codex-tui2` uses the `ratatui` crate, while `tui-scrollbar` is built on `ratatui-core`.
|
||||
//! Because the buffer and style types are distinct, we render the scrollbar into a small
|
||||
//! `ratatui-core` scratch buffer and then copy the resulting glyphs into the main `ratatui`
|
||||
//! buffer with `ratatui` styles.
|
||||
//!
|
||||
//! ## Upgrade note: Ratatui 0.30+
|
||||
//!
|
||||
//! Ratatui 0.30 split many core types (including `Buffer`, `Rect`, and `Widget`) into the new
|
||||
//! `ratatui-core` crate. `codex-tui2` is currently pinned to an older Ratatui, so it still works
|
||||
//! with `ratatui::buffer::Buffer` / `ratatui::layout::Rect`, while `tui-scrollbar` is already on
|
||||
//! `ratatui-core`.
|
||||
//!
|
||||
//! That mismatch forces two bits of "glue" that should go away once `codex-tui2` upgrades to
|
||||
//! Ratatui 0.30:
|
||||
//!
|
||||
//! - Rendering: `render_transcript_scrollbar_if_active` currently renders into a `ratatui-core`
|
||||
//! scratch buffer and copies glyphs/styles into the `ratatui` buffer. With Ratatui 0.30, the
|
||||
//! app’s buffer/rect types should unify with `tui-scrollbar`’s `ratatui-core` types, so we can
|
||||
//! render directly without copying.
|
||||
//! - Input: we currently translate `crossterm::MouseEvent` into `tui-scrollbar`’s backend-agnostic
|
||||
//! `ScrollEvent` types (and intentionally avoid `tui-scrollbar`’s optional `crossterm` adapter)
|
||||
//! to prevent multiple `crossterm` versions in the dependency graph. Once the Ratatui upgrade
|
||||
//! is complete, this should be revisited; if the workspace’s `crossterm` resolves to a single
|
||||
//! version, we can use `tui-scrollbar`’s adapter and reduce more local glue.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui_core::buffer::Buffer as CoreBuffer;
|
||||
use ratatui_core::layout::Rect as CoreRect;
|
||||
use ratatui_core::widgets::Widget as _;
|
||||
use tui_scrollbar::PointerButton;
|
||||
use tui_scrollbar::PointerEvent;
|
||||
use tui_scrollbar::PointerEventKind;
|
||||
use tui_scrollbar::ScrollBar;
|
||||
use tui_scrollbar::ScrollBarInteraction;
|
||||
use tui_scrollbar::ScrollCommand;
|
||||
use tui_scrollbar::ScrollEvent;
|
||||
use tui_scrollbar::ScrollLengths;
|
||||
use tui_scrollbar::TrackClickBehavior;
|
||||
|
||||
/// Number of columns reserved between transcript content and the scrollbar track.
|
||||
///
|
||||
/// This exists purely for visual separation and to avoid selection/copy UI feeling "attached" to
|
||||
/// the scrollbar.
|
||||
const TRANSCRIPT_SCROLLBAR_GAP_COLS: u16 = 1;
|
||||
/// Width of the scrollbar track itself (in terminal cells).
|
||||
///
|
||||
/// `tui-scrollbar` renders a vertical scrollbar into a 1-column area.
|
||||
const TRANSCRIPT_SCROLLBAR_TRACK_COLS: u16 = 1;
|
||||
/// Total columns reserved for transcript scrollbar UI (gap + track).
|
||||
pub(crate) const TRANSCRIPT_SCROLLBAR_COLS: u16 =
|
||||
TRANSCRIPT_SCROLLBAR_GAP_COLS + TRANSCRIPT_SCROLLBAR_TRACK_COLS;
|
||||
|
||||
/// Split a transcript viewport into content + scrollbar regions.
|
||||
///
|
||||
/// `codex-tui2` reserves space for the transcript scrollbar even when it is not visible so the
|
||||
/// transcript does not "reflow" when the user scrolls away from the bottom.
|
||||
///
|
||||
/// Layout:
|
||||
/// - `content_area`: original area minus [`TRANSCRIPT_SCROLLBAR_COLS`] on the right.
|
||||
/// - `scrollbar_area`: the last column of the original area (1 cell wide).
|
||||
/// - The remaining column (immediately left of `scrollbar_area`) is the "gap" and is intentionally
|
||||
/// left unused so the scrollbar reads as a separate affordance.
|
||||
///
|
||||
/// Returns `(area, None)` when the terminal is too narrow to reserve the required columns.
|
||||
pub(crate) fn split_transcript_area(area: Rect) -> (Rect, Option<Rect>) {
|
||||
if area.width <= TRANSCRIPT_SCROLLBAR_COLS {
|
||||
return (area, None);
|
||||
}
|
||||
|
||||
let content_width = area.width.saturating_sub(TRANSCRIPT_SCROLLBAR_COLS);
|
||||
let content_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: content_width,
|
||||
height: area.height,
|
||||
};
|
||||
let scrollbar_area = Rect {
|
||||
x: area.right().saturating_sub(1),
|
||||
y: area.y,
|
||||
width: TRANSCRIPT_SCROLLBAR_TRACK_COLS,
|
||||
height: area.height,
|
||||
};
|
||||
|
||||
(content_area, Some(scrollbar_area))
|
||||
}
|
||||
|
||||
/// Whether the transcript scrollbar should be visible.
|
||||
///
|
||||
/// The scrollbar is treated as "active" when the transcript is scrollable and the viewport is not
|
||||
/// pinned to the bottom. This is used both for rendering (draw vs. keep blank) and for interaction
|
||||
/// (whether the scrollbar should be hit-testable).
|
||||
///
|
||||
/// Note that `codex-tui2` still reserves space for the scrollbar even when it is inactive; see
|
||||
/// [`split_transcript_area`].
|
||||
pub(crate) fn is_transcript_scrollbar_active(
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
top_offset: usize,
|
||||
) -> bool {
|
||||
if total_lines <= viewport_lines {
|
||||
return false;
|
||||
}
|
||||
|
||||
let max_offset = total_lines.saturating_sub(viewport_lines);
|
||||
top_offset < max_offset
|
||||
}
|
||||
|
||||
/// Render the transcript scrollbar into `buf` when the viewport is scrolled away from bottom.
|
||||
///
|
||||
/// The scrollbar is hidden (but its column(s) remain reserved) while the viewport follows the
|
||||
/// latest output.
|
||||
///
|
||||
/// Implementation notes:
|
||||
/// - We keep `tui-scrollbar`’s default glyph selection and shape logic, but override colors to
|
||||
/// better match `codex-tui2`’s theme heuristics (see module docs).
|
||||
/// - Because `tui-scrollbar` renders into a `ratatui-core` buffer while `codex-tui2` uses `ratatui`
|
||||
/// (pre-0.30), we render into a scratch buffer and then copy the resulting symbols into the main
|
||||
/// buffer.
|
||||
pub(crate) fn render_transcript_scrollbar_if_active(
|
||||
buf: &mut Buffer,
|
||||
scrollbar_area: Option<Rect>,
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
top_offset: usize,
|
||||
) {
|
||||
let Some(scrollbar_area) = scrollbar_area else {
|
||||
return;
|
||||
};
|
||||
|
||||
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if !is_transcript_scrollbar_active(total_lines, viewport_lines, top_offset) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lengths = ScrollLengths {
|
||||
content_len: total_lines,
|
||||
viewport_len: viewport_lines,
|
||||
};
|
||||
|
||||
let scrollbar = ScrollBar::vertical(lengths).offset(top_offset);
|
||||
|
||||
let core_bar_area = CoreRect {
|
||||
x: scrollbar_area.x,
|
||||
y: scrollbar_area.y,
|
||||
width: scrollbar_area.width,
|
||||
height: scrollbar_area.height,
|
||||
};
|
||||
let mut scratch = CoreBuffer::empty(core_bar_area);
|
||||
(&scrollbar).render(core_bar_area, &mut scratch);
|
||||
|
||||
let (track_style, thumb_style) = scrollbar_styles();
|
||||
for row in 0..scrollbar_area.height {
|
||||
let x = scrollbar_area.x;
|
||||
let y = scrollbar_area.y + row;
|
||||
let src = &scratch[(x, y)];
|
||||
let dst = &mut buf[(x, y)];
|
||||
let symbol = src.symbol();
|
||||
dst.set_symbol(symbol);
|
||||
if symbol == " " {
|
||||
dst.set_style(track_style);
|
||||
} else {
|
||||
dst.set_style(thumb_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `crossterm` mouse event into a requested transcript offset for the scrollbar.
|
||||
///
|
||||
/// This is a thin wrapper over `tui-scrollbar`’s pointer interaction logic:
|
||||
/// - It builds a `ScrollBar` configured with the current `top_offset`.
|
||||
/// - It translates the mouse event into a backend-agnostic [`ScrollEvent`].
|
||||
/// - It passes the event through `tui-scrollbar`’s hit testing and drag state (`interaction`).
|
||||
///
|
||||
/// `clamp_to_track` exists for `App`’s "pointer capture" behavior: once the user starts a drag on
|
||||
/// the scrollbar, we keep treating the gesture as a scrollbar drag even if the pointer moves
|
||||
/// outside the 1-column track. Without this clamp, the drag could stop producing offsets, and the
|
||||
/// same mouse gesture could then be interpreted as transcript selection.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// - the scrollbar area is empty,
|
||||
/// - the transcript does not scroll (`total_lines <= viewport_lines`),
|
||||
/// - or the event is not a left-button down/drag/up.
|
||||
pub(crate) fn transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area: Rect,
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
top_offset: usize,
|
||||
mut event: crossterm::event::MouseEvent,
|
||||
interaction: &mut ScrollBarInteraction,
|
||||
clamp_to_track: bool,
|
||||
) -> Option<usize> {
|
||||
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if total_lines <= viewport_lines {
|
||||
return None;
|
||||
}
|
||||
|
||||
if clamp_to_track {
|
||||
let max_x = scrollbar_area.right().saturating_sub(1);
|
||||
let max_y = scrollbar_area.bottom().saturating_sub(1);
|
||||
event.column = event.column.clamp(scrollbar_area.x, max_x);
|
||||
event.row = event.row.clamp(scrollbar_area.y, max_y);
|
||||
}
|
||||
|
||||
let lengths = ScrollLengths {
|
||||
content_len: total_lines,
|
||||
viewport_len: viewport_lines,
|
||||
};
|
||||
let scrollbar = ScrollBar::vertical(lengths)
|
||||
.offset(top_offset)
|
||||
.track_click_behavior(TrackClickBehavior::JumpToClick);
|
||||
|
||||
let core_bar_area = CoreRect {
|
||||
x: scrollbar_area.x,
|
||||
y: scrollbar_area.y,
|
||||
width: scrollbar_area.width,
|
||||
height: scrollbar_area.height,
|
||||
};
|
||||
let scroll_event = match event.kind {
|
||||
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
|
||||
Some(ScrollEvent::Pointer(PointerEvent {
|
||||
column: event.column,
|
||||
row: event.row,
|
||||
kind: PointerEventKind::Down,
|
||||
button: PointerButton::Primary,
|
||||
}))
|
||||
}
|
||||
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
|
||||
Some(ScrollEvent::Pointer(PointerEvent {
|
||||
column: event.column,
|
||||
row: event.row,
|
||||
kind: PointerEventKind::Up,
|
||||
button: PointerButton::Primary,
|
||||
}))
|
||||
}
|
||||
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
|
||||
Some(ScrollEvent::Pointer(PointerEvent {
|
||||
column: event.column,
|
||||
row: event.row,
|
||||
kind: PointerEventKind::Drag,
|
||||
button: PointerButton::Primary,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
scroll_event
|
||||
.and_then(|scroll_event| scrollbar.handle_event(core_bar_area, scroll_event, interaction))
|
||||
.map(|command| match command {
|
||||
ScrollCommand::SetOffset(offset) => offset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive track/thumb styles for the scrollbar from terminal defaults.
|
||||
///
|
||||
/// We prefer using the terminal’s default background/foreground so the scrollbar feels like a
|
||||
/// native part of the theme (and stays readable across 16-color / 256-color / truecolor
|
||||
/// backends).
|
||||
///
|
||||
/// When terminal defaults are unavailable (tests / unsupported terminals), we fall back to fixed
|
||||
/// ANSI colors that are likely to be visible.
|
||||
fn scrollbar_styles() -> (Style, Style) {
|
||||
let Some(terminal_bg) = crate::terminal_palette::default_bg() else {
|
||||
let track_style = Style::new().bg(Color::DarkGray);
|
||||
let thumb_style = Style::new().fg(Color::Gray).bg(Color::DarkGray);
|
||||
return (track_style, thumb_style);
|
||||
};
|
||||
|
||||
let terminal_fg = crate::terminal_palette::default_fg();
|
||||
|
||||
let (track_rgb, thumb_rgb) = scrollbar_colors(terminal_bg, terminal_fg);
|
||||
let track_bg = crate::terminal_palette::best_color(track_rgb);
|
||||
let thumb_fg = crate::terminal_palette::best_color(thumb_rgb);
|
||||
|
||||
let track_style = Style::new().bg(track_bg);
|
||||
let thumb_style = Style::new().fg(thumb_fg).bg(track_bg);
|
||||
(track_style, thumb_style)
|
||||
}
|
||||
|
||||
/// Compute `(track_bg_rgb, thumb_fg_rgb)` for the transcript scrollbar.
|
||||
///
|
||||
/// The scrollbar is styled to feel consistent with the user prompt background (see
|
||||
/// `style::user_message_bg`), but is tuned separately so the thumb reads as an inset control:
|
||||
///
|
||||
/// - Dark themes: track is a subtle brightening of the background; thumb is brighter than the track
|
||||
/// (but not pure white).
|
||||
/// - Light themes: track is a subtle darkening of the background; thumb is darker than the track.
|
||||
fn scrollbar_colors(
|
||||
terminal_bg: (u8, u8, u8),
|
||||
terminal_fg: Option<(u8, u8, u8)>,
|
||||
) -> ((u8, u8, u8), (u8, u8, u8)) {
|
||||
let is_light = crate::color::is_light(terminal_bg);
|
||||
let fallback_fg = if is_light { (0, 0, 0) } else { (255, 255, 255) };
|
||||
let terminal_fg = terminal_fg.unwrap_or(fallback_fg);
|
||||
|
||||
// We want the scrollbar to feel visually related to the user message block background
|
||||
// (`style::user_message_bg` uses 0.1), but slightly more subtle:
|
||||
//
|
||||
// - Light mode: keep both colors closer to the background (alpha < 0.1), with the thumb darker
|
||||
// than the track.
|
||||
// - Dark mode: keep the track slightly darker than the prompt block, but make the thumb
|
||||
// brighter so it's easy to pick out without becoming "white".
|
||||
let (track_alpha, thumb_alpha) = if is_light { (0.04, 0.08) } else { (0.08, 0.18) };
|
||||
|
||||
let track_rgb = crate::color::blend(terminal_fg, terminal_bg, track_alpha);
|
||||
let thumb_rgb = crate::color::blend(terminal_fg, terminal_bg, thumb_alpha);
|
||||
(track_rgb, thumb_rgb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn scrollbar_bg(buf: &Buffer, scrollbar_area: Rect) -> Vec<ratatui::style::Color> {
|
||||
use ratatui::style::Color;
|
||||
|
||||
let x = scrollbar_area.x;
|
||||
(0..scrollbar_area.height)
|
||||
.map(|row| {
|
||||
buf[(x, scrollbar_area.y + row)]
|
||||
.style()
|
||||
.bg
|
||||
.unwrap_or(Color::Reset)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_render_when_pinned_to_bottom() {
|
||||
let full_area = Rect::new(0, 0, 10, 6);
|
||||
let (_, scrollbar_area) = split_transcript_area(full_area);
|
||||
let mut buf = Buffer::empty(full_area);
|
||||
|
||||
render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 94);
|
||||
|
||||
assert_eq!(
|
||||
scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")),
|
||||
vec![
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_when_scrolled_away_from_bottom() {
|
||||
let full_area = Rect::new(0, 0, 10, 6);
|
||||
let (_, scrollbar_area) = split_transcript_area(full_area);
|
||||
let mut buf = Buffer::empty(full_area);
|
||||
|
||||
render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 80);
|
||||
|
||||
assert_eq!(
|
||||
scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")),
|
||||
vec![
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_leaves_gap_before_scrollbar() {
|
||||
let full_area = Rect::new(0, 0, 10, 6);
|
||||
let (content, scrollbar) = split_transcript_area(full_area);
|
||||
|
||||
assert_eq!(content.width, 8);
|
||||
assert_eq!(scrollbar.expect("scrollbar").x, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_mouse_drag_moves_offset_downward() {
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
|
||||
let scrollbar_area = Rect::new(9, 0, 1, 10);
|
||||
let mut interaction = ScrollBarInteraction::new();
|
||||
|
||||
let down = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 9,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
};
|
||||
let mut offset = 0;
|
||||
if let Some(next) = transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area,
|
||||
100,
|
||||
10,
|
||||
offset,
|
||||
down,
|
||||
&mut interaction,
|
||||
true,
|
||||
) {
|
||||
offset = next;
|
||||
}
|
||||
|
||||
let drag = MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 9,
|
||||
row: 9,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
};
|
||||
let dragged = transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area,
|
||||
100,
|
||||
10,
|
||||
offset,
|
||||
drag,
|
||||
&mut interaction,
|
||||
true,
|
||||
)
|
||||
.expect("drag should set an offset");
|
||||
|
||||
assert!(dragged > offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_thumb_is_darker_than_track() {
|
||||
let bg = (255, 255, 255);
|
||||
let fg = Some((0, 0, 0));
|
||||
let (track, thumb) = scrollbar_colors(bg, fg);
|
||||
|
||||
assert!(thumb.0 < track.0);
|
||||
assert!(thumb.1 < track.1);
|
||||
assert!(thumb.2 < track.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_mode_thumb_is_brighter_than_track() {
|
||||
let bg = (0, 0, 0);
|
||||
let fg = Some((255, 255, 255));
|
||||
let (track, thumb) = scrollbar_colors(bg, fg);
|
||||
|
||||
assert!(thumb.0 > track.0);
|
||||
assert!(thumb.1 > track.1);
|
||||
assert!(thumb.2 > track.2);
|
||||
}
|
||||
}
|
||||
225
codex-rs/tui2/src/transcript_scrollbar_ui.rs
Normal file
225
codex-rs/tui2/src/transcript_scrollbar_ui.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
//! Transcript scrollbar mouse interaction.
|
||||
//!
|
||||
//! This module handles pointer interaction (click/drag) for the transcript scrollbar rendered by
|
||||
//! [`crate::transcript_scrollbar`]. It exists to keep `app.rs` from growing further: the transcript
|
||||
//! is a particularly stateful part of the UI (selection, wrapping, scroll anchoring, copy, etc.),
|
||||
//! and scrollbar interaction needs to coordinate with several of those subsystems.
|
||||
//!
|
||||
//! # Responsibilities
|
||||
//!
|
||||
//! - Translate `crossterm` mouse events into `tui-scrollbar` interaction events (backend-agnostic
|
||||
//! [`tui_scrollbar::ScrollEvent`]).
|
||||
//! - Maintain `tui-scrollbar`’s drag interaction state (`ScrollBarInteraction`) across frames so
|
||||
//! the thumb "grab offset" behaves naturally.
|
||||
//! - Maintain a tiny "pointer capture" flag so a drag that reaches the bottom doesn't fall through
|
||||
//! into transcript selection once the scrollbar becomes visually hidden (because the view is now
|
||||
//! pinned to bottom).
|
||||
//!
|
||||
//! This module does *not* render anything. Rendering lives in `transcript_scrollbar.rs`.
|
||||
//!
|
||||
//! # Interaction model and transcript anchors
|
||||
//!
|
||||
//! `tui-scrollbar` reports requested scroll positions as a raw `offset` (a top-row index). The
|
||||
//! transcript scroll state in `codex-tui2` is represented as a stable anchor
|
||||
//! ([`crate::tui::scrolling::TranscriptScroll`]) so it survives transcript growth and re-wrapping.
|
||||
//!
|
||||
//! The conversion happens here:
|
||||
//! - Ask `tui-scrollbar` for a `next_offset`.
|
||||
//! - Convert that concrete offset back into a stable anchor using
|
||||
//! [`crate::tui::scrolling::TranscriptScroll::anchor_for`].
|
||||
//! - If the requested offset is the bottom-most valid position, use `ToBottom` rather than a fixed
|
||||
//! anchor, restoring auto-follow behavior.
|
||||
//!
|
||||
//! This keeps scrollbar interaction consistent with other scroll mechanisms (wheel, PgUp/PgDn),
|
||||
//! which also operate in terms of the `TranscriptScroll` state machine.
|
||||
//!
|
||||
//! # Upgrade note: Ratatui 0.30+
|
||||
//!
|
||||
//! This module intentionally uses `tui-scrollbar`’s backend-agnostic event types instead of its
|
||||
//! optional `crossterm` adapter. The workspace uses a patched `crossterm`, and enabling the adapter
|
||||
//! would pull in a second `crossterm` version, making `MouseEvent` types incompatible.
|
||||
//!
|
||||
//! Once `codex-tui2` upgrades to Ratatui 0.30 (and the workspace converges on a single `crossterm`
|
||||
//! version), we should revisit whether we can remove this translation layer.
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::transcript_scrollbar::is_transcript_scrollbar_active;
|
||||
use crate::transcript_scrollbar::transcript_scrollbar_offset_for_mouse_event;
|
||||
use crate::transcript_view_cache::TranscriptViewCache;
|
||||
use crate::tui;
|
||||
use crate::tui::scrolling::MouseScrollState;
|
||||
use crate::tui::scrolling::TranscriptScroll;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TranscriptScrollbarMouseHandling {
|
||||
/// The event is unrelated to the scrollbar; callers may handle it normally (e.g. selection).
|
||||
NotHandled,
|
||||
/// The event was handled by the scrollbar logic and should not be interpreted as selection.
|
||||
Handled,
|
||||
}
|
||||
|
||||
/// Persistent UI state for transcript scrollbar pointer interaction.
|
||||
///
|
||||
/// This stores `tui-scrollbar`’s drag state (`ScrollBarInteraction`) plus a small "pointer capture"
|
||||
/// flag used by `codex-tui2`:
|
||||
///
|
||||
/// - When the user clicks the scrollbar thumb/track, we enter pointer capture.
|
||||
/// - While capture is active, subsequent drag events are treated as scrollbar drags even if the
|
||||
/// pointer leaves the 1-column track.
|
||||
/// - Capture is released on `MouseUp`.
|
||||
///
|
||||
/// The capture flag is important because the transcript scrollbar is hidden while pinned to
|
||||
/// bottom; without capture, a drag that reaches the bottom could stop producing offsets and fall
|
||||
/// through into transcript selection mid-gesture.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TranscriptScrollbarUi {
|
||||
interaction: tui_scrollbar::ScrollBarInteraction,
|
||||
pointer_capture: bool,
|
||||
}
|
||||
|
||||
/// Bundles the arguments needed to handle a transcript scrollbar mouse event.
|
||||
///
|
||||
/// This is intentionally a struct (rather than a long argument list) because scrollbar interaction
|
||||
/// touches several pieces of transcript state at once: wrapping cache, scroll anchor state, the
|
||||
/// concrete top-row offset, and the wheel-scroll stream state machine. Grouping them makes call
|
||||
/// sites easier to scan and helps keep `app.rs` glue minimal.
|
||||
pub(crate) struct TranscriptScrollbarMouseEvent<'a> {
|
||||
pub(crate) tui: &'a mut tui::Tui,
|
||||
pub(crate) mouse_event: MouseEvent,
|
||||
pub(crate) transcript_area: Rect,
|
||||
pub(crate) scrollbar_area: Option<Rect>,
|
||||
pub(crate) transcript_cells: &'a [Arc<dyn HistoryCell>],
|
||||
pub(crate) transcript_view_cache: &'a mut TranscriptViewCache,
|
||||
pub(crate) transcript_scroll: &'a mut TranscriptScroll,
|
||||
pub(crate) transcript_view_top: &'a mut usize,
|
||||
pub(crate) transcript_total_lines: &'a mut usize,
|
||||
pub(crate) mouse_scroll_state: &'a mut MouseScrollState,
|
||||
}
|
||||
|
||||
impl TranscriptScrollbarUi {
|
||||
pub(crate) fn pointer_capture_active(&self) -> bool {
|
||||
self.pointer_capture
|
||||
}
|
||||
|
||||
/// Handle click/drag events for the transcript scrollbar.
|
||||
///
|
||||
/// The caller is expected to provide the transcript layout for the current terminal size:
|
||||
/// `transcript_area` for content and `scrollbar_area` for the 1-column scrollbar track. See
|
||||
/// [`crate::transcript_scrollbar::split_transcript_area`].
|
||||
///
|
||||
/// Returns [`TranscriptScrollbarMouseHandling::Handled`] when the event should not be
|
||||
/// interpreted as transcript selection (either because it updated the scroll position or
|
||||
/// because an in-progress scrollbar drag is being captured).
|
||||
pub(crate) fn handle_mouse_event(
|
||||
&mut self,
|
||||
event: TranscriptScrollbarMouseEvent<'_>,
|
||||
) -> TranscriptScrollbarMouseHandling {
|
||||
let TranscriptScrollbarMouseEvent {
|
||||
tui,
|
||||
mouse_event,
|
||||
transcript_area,
|
||||
scrollbar_area,
|
||||
transcript_cells,
|
||||
transcript_view_cache,
|
||||
transcript_scroll,
|
||||
transcript_view_top,
|
||||
transcript_total_lines,
|
||||
mouse_scroll_state,
|
||||
} = event;
|
||||
let is_scrollbar_event = matches!(
|
||||
mouse_event.kind,
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
| MouseEventKind::Drag(MouseButton::Left)
|
||||
| MouseEventKind::Up(MouseButton::Left)
|
||||
);
|
||||
if !is_scrollbar_event {
|
||||
return TranscriptScrollbarMouseHandling::NotHandled;
|
||||
}
|
||||
|
||||
let Some(scrollbar_area) = scrollbar_area else {
|
||||
if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) {
|
||||
self.pointer_capture = false;
|
||||
}
|
||||
return if self.pointer_capture {
|
||||
TranscriptScrollbarMouseHandling::Handled
|
||||
} else {
|
||||
TranscriptScrollbarMouseHandling::NotHandled
|
||||
};
|
||||
};
|
||||
|
||||
let is_over_scrollbar = mouse_event.column >= scrollbar_area.x
|
||||
&& mouse_event.column < scrollbar_area.right()
|
||||
&& mouse_event.row >= scrollbar_area.y
|
||||
&& mouse_event.row < scrollbar_area.bottom();
|
||||
|
||||
if !self.pointer_capture && !is_over_scrollbar {
|
||||
return TranscriptScrollbarMouseHandling::NotHandled;
|
||||
}
|
||||
|
||||
let viewport_lines = transcript_area.height as usize;
|
||||
let scrollbar_is_visible = if viewport_lines > 0 && !transcript_cells.is_empty() {
|
||||
transcript_view_cache.ensure_wrapped(transcript_cells, transcript_area.width);
|
||||
let total_lines = transcript_view_cache.lines().len();
|
||||
let max_visible = std::cmp::min(total_lines, viewport_lines);
|
||||
is_transcript_scrollbar_active(total_lines, max_visible, *transcript_view_top)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// When the transcript is pinned to bottom, we intentionally hide the scrollbar (but still
|
||||
// reserve its column). In that state, we avoid hit-testing the scrollbar track so the
|
||||
// reserved column doesn't become an invisible interactive region. Pointer capture remains
|
||||
// active for an in-progress drag so a gesture that reaches the bottom doesn't fall through
|
||||
// into transcript selection mid-drag.
|
||||
if !self.pointer_capture && !scrollbar_is_visible {
|
||||
return TranscriptScrollbarMouseHandling::NotHandled;
|
||||
}
|
||||
|
||||
if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) && is_over_scrollbar
|
||||
{
|
||||
self.pointer_capture = true;
|
||||
}
|
||||
|
||||
if viewport_lines > 0 && !transcript_cells.is_empty() {
|
||||
// `ensure_wrapped` was already called above when checking visibility.
|
||||
let total_lines = transcript_view_cache.lines().len();
|
||||
let max_visible = std::cmp::min(total_lines, viewport_lines);
|
||||
let max_offset = total_lines.saturating_sub(max_visible);
|
||||
|
||||
if let Some(next_offset) = transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area,
|
||||
total_lines,
|
||||
max_visible,
|
||||
*transcript_view_top,
|
||||
mouse_event,
|
||||
&mut self.interaction,
|
||||
self.pointer_capture,
|
||||
) {
|
||||
let next_offset = next_offset.min(max_offset);
|
||||
let line_meta = transcript_view_cache.line_meta();
|
||||
|
||||
*transcript_scroll = if next_offset >= max_offset {
|
||||
TranscriptScroll::ToBottom
|
||||
} else {
|
||||
TranscriptScroll::anchor_for(line_meta, next_offset)
|
||||
.unwrap_or(TranscriptScroll::ToBottom)
|
||||
};
|
||||
*transcript_view_top = next_offset.min(max_offset);
|
||||
*transcript_total_lines = total_lines;
|
||||
*mouse_scroll_state = MouseScrollState::default();
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) {
|
||||
self.pointer_capture = false;
|
||||
}
|
||||
|
||||
TranscriptScrollbarMouseHandling::Handled
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue