mod changelog;
mod help;
mod help_content;
mod help_session;
pub mod history_browser;
pub mod history_schema;
pub(crate) mod markdown;
pub mod session_info;
pub(crate) mod style_convert;
pub(crate) mod text_utils;
pub use changelog::display_changelog;
pub(crate) use help::display_prepared_help_request;
pub use help::run_help_browser;
pub use history_browser::{HistoryBrowserResult, HistoryDbMode, run_history_browser};
pub use session_info::display_session_info;
use base64::{Engine, engine::general_purpose};
use crossterm::{
Command, ExecutableCommand, cursor,
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseEventKind,
},
queue,
terminal::{
self, BeginSynchronizedUpdate, ClearType, EndSynchronizedUpdate, EnterAlternateScreen,
LeaveAlternateScreen,
},
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color as RatColor, Style as RatStyle};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use std::io::{self, Write};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] pub enum PagerAction {
Continue,
Exit,
Redraw,
ScrollTo(usize),
}
pub(crate) const SCROLL_INTERVAL_MS: u64 = 150;
pub(crate) const SCROLL_PAUSE_MS: u64 = 1000;
struct AlternateScreenGuard;
impl Drop for AlternateScreenGuard {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let mut stdout = io::stdout();
let _ = stdout.execute(DisableMouseCapture);
let _ = stdout.execute(cursor::Show);
let _ = stdout.execute(LeaveAlternateScreen);
}
}
pub fn with_alternate_screen<R, F>(f: F) -> io::Result<R>
where
F: FnOnce() -> io::Result<R>,
{
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let _guard = AlternateScreenGuard;
stdout.execute(EnableMouseCapture)?;
terminal::enable_raw_mode()?;
f()
}
pub(crate) struct TextScrollState {
pub scroll_pos: usize,
prev_cursor: usize,
scroll_start: Instant,
}
impl TextScrollState {
pub fn new() -> Self {
Self {
scroll_pos: 0,
prev_cursor: usize::MAX,
scroll_start: Instant::now(),
}
}
pub fn update(&mut self, current_cursor: usize) -> bool {
if current_cursor != self.prev_cursor {
self.prev_cursor = current_cursor;
self.scroll_pos = 0;
self.scroll_start = Instant::now();
return true;
}
let elapsed = self.scroll_start.elapsed();
if elapsed < Duration::from_millis(SCROLL_PAUSE_MS) {
return false;
}
let scroll_time = elapsed - Duration::from_millis(SCROLL_PAUSE_MS);
let new_pos = (scroll_time.as_millis() / SCROLL_INTERVAL_MS as u128) as usize;
if new_pos != self.scroll_pos {
self.scroll_pos = new_pos;
true
} else {
false
}
}
}
pub struct PagerConfig<'a> {
pub title: &'a str,
pub footer_hint: &'a str,
pub manage_alternate_screen: bool,
}
impl<'a> Default for PagerConfig<'a> {
fn default() -> Self {
Self {
title: "Pager",
footer_hint: "↑↓/jk scroll q/Esc exit",
manage_alternate_screen: true,
}
}
}
pub trait PagerContent {
fn title(&self) -> Option<&str> {
None
}
fn line_count(&self) -> usize;
fn render_line(&self, index: usize, width: usize) -> Line<'static>;
fn prepare_render(&mut self, _scroll_offset: usize) {}
fn take_scroll_request(&mut self) -> Option<usize> {
None
}
fn handle_key(&mut self, _code: KeyCode, _modifiers: KeyModifiers) -> Option<PagerAction> {
None
}
fn feedback_message(&self) -> Option<&str> {
None
}
fn clear_feedback(&mut self) {}
fn on_resize(&mut self, _width: usize, _height: usize) {}
}
pub fn run<C: PagerContent>(content: &mut C, config: &PagerConfig) -> io::Result<()> {
if config.manage_alternate_screen {
with_alternate_screen(|| run_inner(content, config))
} else {
run_inner(content, config)
}
}
fn run_inner<C: PagerContent>(content: &mut C, config: &PagerConfig) -> io::Result<()> {
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
terminal.hide_cursor()?;
let mut scroll_offset = 0;
let mut needs_redraw = true;
let (_, initial_rows) = terminal::size().unwrap_or((80, 24));
let mut term_height = initial_rows as usize;
loop {
if needs_redraw {
if let Some(line) = content.take_scroll_request() {
scroll_offset = clamp_scroll_offset(line, content.line_count(), term_height);
}
content.prepare_render(scroll_offset);
render(&mut terminal, content, config, scroll_offset)?;
needs_redraw = false;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
content.clear_feedback();
needs_redraw = true;
if let Some(action) = content.handle_key(key.code, key.modifiers) {
match action {
PagerAction::Exit => break,
PagerAction::Redraw => continue,
PagerAction::ScrollTo(line) => {
scroll_offset =
clamp_scroll_offset(line, content.line_count(), term_height);
continue;
}
PagerAction::Continue => {}
}
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _)
| (KeyCode::Char('q'), KeyModifiers::NONE)
| (KeyCode::Char('c'), KeyModifiers::CONTROL)
| (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
break;
}
(KeyCode::Up, _)
| (KeyCode::Char('k'), KeyModifiers::NONE)
| (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
scroll_offset = scroll_offset.saturating_sub(1);
}
(KeyCode::Down, _)
| (KeyCode::Char('j'), KeyModifiers::NONE)
| (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
let max_offset =
max_scroll_offset_with_height(content.line_count(), term_height);
if scroll_offset < max_offset {
scroll_offset += 1;
}
}
(KeyCode::PageUp, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => {
let page_size = content_rows_with_height(term_height);
scroll_offset = scroll_offset.saturating_sub(page_size);
}
(KeyCode::PageDown, _)
| (KeyCode::Char(' '), KeyModifiers::NONE)
| (KeyCode::Char('f'), KeyModifiers::CONTROL) => {
let page_size = content_rows_with_height(term_height);
let max_offset =
max_scroll_offset_with_height(content.line_count(), term_height);
scroll_offset = (scroll_offset + page_size).min(max_offset);
}
(KeyCode::Home, _) | (KeyCode::Char('g'), KeyModifiers::NONE) => {
scroll_offset = 0;
}
(KeyCode::End, _) | (KeyCode::Char('G'), KeyModifiers::SHIFT) => {
scroll_offset =
max_scroll_offset_with_height(content.line_count(), term_height);
}
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
needs_redraw = true;
scroll_offset = scroll_offset.saturating_sub(1);
}
MouseEventKind::ScrollDown => {
needs_redraw = true;
let max_offset =
max_scroll_offset_with_height(content.line_count(), term_height);
if scroll_offset < max_offset {
scroll_offset += 1;
}
}
_ => {}
},
Event::Resize(w, h) => {
term_height = h as usize;
content.on_resize(w as usize, term_height);
scroll_offset =
clamp_scroll_offset(scroll_offset, content.line_count(), term_height);
needs_redraw = true;
}
_ => {}
}
}
}
Ok(())
}
fn render<C: PagerContent>(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
content: &C,
config: &PagerConfig,
scroll_offset: usize,
) -> io::Result<()> {
terminal.draw(|frame| {
let area = frame.area();
let width = area.width as usize;
let [header_area, content_area, footer_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(area);
let header_text = format!(
"─ {} [{}/{}] ─",
content.title().unwrap_or(config.title),
scroll_offset + 1,
content.line_count().max(1)
);
let padded_header = format!("{:─<width$}", header_text, width = width);
let header = Paragraph::new(Span::styled(
padded_header,
RatStyle::default().fg(RatColor::DarkGray),
));
frame.render_widget(header, header_area);
let visible_rows = content_area.height as usize;
let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible_rows);
for i in 0..visible_rows {
let line_idx = scroll_offset + i;
if line_idx < content.line_count() {
let mut line = content.render_line(line_idx, width);
if line.style.bg.is_some() {
let current_w: usize = line.spans.iter().map(|s| s.width()).sum();
if current_w < width {
line.spans.push(Span::raw(" ".repeat(width - current_w)));
}
}
lines.push(line);
} else {
lines.push(Line::from(""));
}
}
let content_widget = Paragraph::new(lines);
frame.render_widget(content_widget, content_area);
let footer_text = if let Some(msg) = content.feedback_message() {
format!("─ {} ─", msg)
} else {
format!("─ {} ─", config.footer_hint)
};
let padded_footer = format!("{:─<width$}", footer_text, width = width);
let footer = Paragraph::new(Span::styled(
padded_footer,
RatStyle::default().fg(RatColor::DarkGray),
));
frame.render_widget(footer, footer_area);
})?;
Ok(())
}
fn content_rows_with_height(height: usize) -> usize {
height.saturating_sub(2)
}
fn max_scroll_offset_with_height(line_count: usize, height: usize) -> usize {
line_count.saturating_sub(content_rows_with_height(height))
}
fn clamp_scroll_offset(requested: usize, line_count: usize, height: usize) -> usize {
requested.min(max_scroll_offset_with_height(line_count, height))
}
struct SetClipboard {
content: String,
}
impl SetClipboard {
fn new(content: &str) -> Self {
Self {
content: general_purpose::STANDARD.encode(content.as_bytes()),
}
}
}
impl Command for SetClipboard {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
write!(f, "\x1b]52;c;{}\x1b\\", self.content)
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
Ok(())
}
}
pub fn copy_to_clipboard(text: &str) -> io::Result<()> {
use std::io::BufWriter;
let mut writer = BufWriter::new(io::stderr());
crossterm::execute!(writer, SetClipboard::new(text))
}
pub(crate) struct MinimumSize {
pub cols: u16,
pub rows: u16,
}
pub(crate) fn render_size_warning(
stdout: &mut io::Stdout,
cols: u16,
rows: u16,
min: &MinimumSize,
) -> io::Result<()> {
let width = cols as usize;
let height = rows as usize;
let title = "Terminal too small";
let current_line = format!("Current: {}x{}", cols, rows);
let minimum_line = format!("Minimum: {}x{}", min.cols, min.rows);
let messages = [
title,
"",
¤t_line,
&minimum_line,
"",
"Please resize your terminal.",
"",
"Press q or Esc to exit.",
];
queue!(
stdout,
BeginSynchronizedUpdate,
cursor::MoveTo(0, 0),
cursor::Hide
)?;
let start_row = height.saturating_sub(messages.len()) / 2;
for row in 0..height {
queue!(stdout, terminal::Clear(ClearType::CurrentLine))?;
if row >= start_row && row < start_row + messages.len() {
let msg = messages[row - start_row];
let msg_width = text_utils::display_width(msg);
let padding = width.saturating_sub(msg_width) / 2;
if msg == title {
write!(
stdout,
"\r{}{}",
" ".repeat(padding),
crossterm::style::Stylize::bold(crossterm::style::Stylize::yellow(msg))
)?;
} else {
write!(stdout, "\r{}{}", " ".repeat(padding), msg)?;
}
}
if row + 1 < height {
write!(stdout, "\r\n")?;
}
}
queue!(stdout, EndSynchronizedUpdate)?;
stdout.flush()?;
Ok(())
}
pub(crate) fn check_terminal_too_small(min: &MinimumSize) -> Option<(u16, u16)> {
let (cols, rows) = terminal::size().unwrap_or((80, 24));
if is_below_minimum(cols, rows, min) {
Some((cols, rows))
} else {
None
}
}
fn is_below_minimum(cols: u16, rows: u16, min: &MinimumSize) -> bool {
cols < min.cols || rows < min.rows
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_scroll_requests_are_clamped_to_the_viewport_range() {
assert_eq!(clamp_scroll_offset(usize::MAX, 100, 24), 78);
assert_eq!(clamp_scroll_offset(10, 100, 24), 10);
assert_eq!(clamp_scroll_offset(20, 5, 24), 0);
assert_eq!(clamp_scroll_offset(1, 0, 24), 0);
assert_eq!(clamp_scroll_offset(78, 100, 50), 52);
}
#[test]
fn test_is_below_minimum_both_ok() {
let min = MinimumSize { cols: 70, rows: 10 };
assert!(!is_below_minimum(80, 24, &min));
assert!(!is_below_minimum(70, 10, &min)); }
#[test]
fn test_is_below_minimum_cols_too_small() {
let min = MinimumSize { cols: 70, rows: 10 };
assert!(is_below_minimum(69, 24, &min));
}
#[test]
fn test_is_below_minimum_rows_too_small() {
let min = MinimumSize { cols: 70, rows: 10 };
assert!(is_below_minimum(80, 9, &min));
}
#[test]
fn test_is_below_minimum_both_too_small() {
let min = MinimumSize { cols: 70, rows: 10 };
assert!(is_below_minimum(40, 5, &min));
}
}