Files
llmx/codex-rs/tui/src/pager_overlay.rs

460 lines
15 KiB
Rust
Raw Normal View History

use std::io::Result;
use std::time::Duration;
use crate::insert_history;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
pub(crate) enum Overlay {
Transcript(TranscriptOverlay),
Static(StaticOverlay),
}
impl Overlay {
pub(crate) fn new_transcript(lines: Vec<Line<'static>>) -> Self {
Self::Transcript(TranscriptOverlay::new(lines))
}
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self::Static(StaticOverlay::with_title(lines, title))
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match self {
Overlay::Transcript(o) => o.handle_event(tui, event),
Overlay::Static(o) => o.handle_event(tui, event),
}
}
pub(crate) fn is_done(&self) -> bool {
match self {
Overlay::Transcript(o) => o.is_done(),
Overlay::Static(o) => o.is_done(),
}
}
}
// Common pager navigation hints rendered on the first line
const PAGER_KEY_HINTS: &[(&str, &str)] = &[
("↑/↓", "scroll"),
("PgUp/PgDn", "page"),
("Home/End", "jump"),
];
// Render a single line of key hints from (key, description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
let key_hint_style = Style::default().fg(Color::Cyan);
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (key, desc) in pairs {
if !first {
spans.push(" ".into());
}
spans.push(Span::from(key.to_string()).set_style(key_hint_style));
spans.push(" ".into());
spans.push(Span::from(desc.to_string()));
first = false;
}
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
}
/// Generic widget for rendering a pager view.
struct PagerView {
lines: Vec<Line<'static>>,
scroll_offset: usize,
title: String,
}
impl PagerView {
fn new(lines: Vec<Line<'static>>, title: String, scroll_offset: usize) -> Self {
Self {
lines,
scroll_offset,
title,
}
}
fn render(&mut self, area: Rect, buf: &mut Buffer) {
self.render_header(area, buf);
let content_area = self.scroll_area(area);
let wrapped = insert_history::word_wrap_lines(&self.lines, content_area.width);
self.render_content_page(content_area, buf, &wrapped);
self.render_bottom_bar(area, content_area, buf, &wrapped);
}
fn render_header(&self, area: Rect, buf: &mut Buffer) {
Span::from("/ ".repeat(area.width as usize / 2))
.dim()
.render_ref(area, buf);
let header = format!("/ {}", self.title);
Span::from(header).dim().render_ref(area, buf);
}
fn render_content_page(&mut self, area: Rect, buf: &mut Buffer, wrapped: &[Line<'static>]) {
self.scroll_offset = self
.scroll_offset
.min(wrapped.len().saturating_sub(area.height as usize));
let start = self.scroll_offset;
let end = (start + area.height as usize).min(wrapped.len());
let page = &wrapped[start..end];
Paragraph::new(page.to_vec()).render_ref(area, buf);
let visible = end.saturating_sub(start);
if visible < area.height as usize {
for i in 0..(area.height as usize - visible) {
let add = ((visible + i).min(u16::MAX as usize)) as u16;
let y = area.y.saturating_add(add);
Span::from("~")
.dim()
.render_ref(Rect::new(area.x, y, 1, 1), buf);
}
}
}
fn render_bottom_bar(
&self,
full_area: Rect,
content_area: Rect,
buf: &mut Buffer,
wrapped: &[Line<'static>],
) {
let sep_y = content_area.bottom();
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
Span::from("".repeat(sep_rect.width as usize))
.dim()
.render_ref(sep_rect, buf);
let percent = if wrapped.is_empty() {
100
} else {
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
if max_scroll == 0 {
100
} else {
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
as u8
}
};
let pct_text = format!(" {percent}% ");
let pct_w = pct_text.chars().count() as u16;
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
Span::from(pct_text)
.dim()
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
match key_event {
KeyEvent {
code: KeyCode::Up,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
KeyEvent {
code: KeyCode::Down,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
KeyEvent {
code: KeyCode::PageUp,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.scroll_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
}
KeyEvent {
code: KeyCode::PageDown | KeyCode::Char(' '),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.scroll_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
}
KeyEvent {
code: KeyCode::Home,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = 0;
}
KeyEvent {
code: KeyCode::End,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = usize::MAX;
}
_ => {
return Ok(());
}
}
tui.frame_requester()
.schedule_frame_in(Duration::from_millis(16));
Ok(())
}
fn scroll_area(&self, area: Rect) -> Rect {
let mut area = area;
area.y = area.y.saturating_add(1);
area.height = area.height.saturating_sub(2);
area
}
}
pub(crate) struct TranscriptOverlay {
view: PagerView,
highlight_range: Option<(usize, usize)>,
is_done: bool,
}
impl TranscriptOverlay {
pub(crate) fn new(transcript_lines: Vec<Line<'static>>) -> Self {
Self {
view: PagerView::new(
transcript_lines,
"T R A N S C R I P T".to_string(),
usize::MAX,
),
highlight_range: None,
is_done: false,
}
}
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
self.view.lines.extend(lines);
}
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
self.highlight_range = range;
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")];
if let Some((start, end)) = self.highlight_range
&& end > start
{
pairs.push(("", "edit message"));
}
render_key_hints(line2, buf, &pairs);
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
let top_h = area.height.saturating_sub(3);
let top = Rect::new(area.x, area.y, area.width, top_h);
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
// Build highlighted lines into a temporary view for this render only
let mut lines = self.view.lines.clone();
if let Some((start, end)) = self.highlight_range {
use ratatui::style::Modifier;
let len = lines.len();
let start = start.min(len);
let end = end.min(len);
for (idx, line) in lines.iter_mut().enumerate().take(end).skip(start) {
let mut spans = Vec::with_capacity(line.spans.len());
for (i, s) in line.spans.iter().enumerate() {
let mut style = s.style;
style.add_modifier |= Modifier::REVERSED;
if idx == start && i == 0 {
style.add_modifier |= Modifier::BOLD;
}
spans.push(Span {
style,
content: s.content.clone(),
});
}
line.spans = spans;
}
}
let mut pv = PagerView::new(lines, self.view.title.clone(), self.view.scroll_offset);
pv.render(top, buf);
self.view.scroll_offset = pv.scroll_offset;
self.render_hints(bottom, buf);
}
}
impl TranscriptOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.is_done = true;
Ok(())
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
Ok(())
}
_ => Ok(()),
}
}
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
pub(crate) fn set_scroll_offset(&mut self, offset: usize) {
self.view.scroll_offset = offset;
}
}
pub(crate) struct StaticOverlay {
view: PagerView,
is_done: bool,
}
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self {
view: PagerView::new(lines, title, 0),
is_done: false,
}
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let pairs = [("q", "quit")];
render_key_hints(line2, buf, &pairs);
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
let top_h = area.height.saturating_sub(3);
let top = Rect::new(area.x, area.y, area.width, top_h);
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
self.view.render(top, buf);
self.render_hints(bottom, buf);
}
}
impl StaticOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.is_done = true;
Ok(())
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
Ok(())
}
_ => Ok(()),
}
}
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn edit_prev_hint_is_visible() {
let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]);
// Render into a small buffer and assert the backtrack hint is present
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
// Flatten buffer to a string and check for the hint text
let mut s = String::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
s.push('\n');
}
assert!(
s.contains("edit prev"),
"expected 'edit prev' hint in overlay footer, got: {s:?}"
);
}
#[test]
fn transcript_overlay_snapshot_basic() {
// Prepare a transcript overlay with a few lines
let mut overlay = TranscriptOverlay::new(vec![
Line::from("alpha"),
Line::from("beta"),
Line::from("gamma"),
]);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
let mut overlay = StaticOverlay::with_title(
vec![Line::from("one"), Line::from("two"), Line::from("three")],
"S T A T I C".to_string(),
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
}