use crate::terminal_extensions::semantic_prompt::{PromptKind, SemanticPromptMarkers};
use crate::{CursorConfig, PromptEditMode, PromptViMode};
use {
super::utils::{coerce_crlf, estimate_required_lines, line_width},
crate::{
menu::{Menu, ReedlineMenu},
painting::PromptLines,
Prompt,
},
crossterm::{
cursor::{self, MoveTo, RestorePosition, SavePosition},
style::{Attribute, Print, ResetColor, SetAttribute, SetForegroundColor},
terminal::{self, Clear, ClearType},
QueueableCommand,
},
std::io::{Result, Write},
std::ops::RangeInclusive,
unicode_segmentation::UnicodeSegmentation,
unicode_width::UnicodeWidthStr,
};
#[cfg(feature = "external_printer")]
use {crate::LineBuffer, crossterm::cursor::MoveUp};
fn skip_buffer_lines(string: &str, skip: usize, offset: Option<usize>) -> &str {
let mut matches = string.match_indices('\n');
let index = if skip == 0 {
0
} else {
matches
.clone()
.nth(skip - 1)
.map(|(index, _)| index + 1)
.unwrap_or(string.len())
};
let limit = match offset {
Some(offset) => {
let offset = skip + offset;
matches
.nth(offset)
.map(|(index, _)| index)
.unwrap_or(string.len())
}
None => string.len(),
};
string[index..limit].trim_end_matches('\n')
}
fn skip_buffer_lines_range(string: &str, skip: usize, offset: Option<usize>) -> (usize, usize) {
let mut matches = string.match_indices('\n');
let index = if skip == 0 {
0
} else {
matches
.clone()
.nth(skip - 1)
.map(|(index, _)| index + 1)
.unwrap_or(string.len())
};
let limit = match offset {
Some(offset) => {
let offset = skip + offset;
matches
.nth(offset)
.map(|(index, _)| index)
.unwrap_or(string.len())
}
None => string.len(),
};
(index, limit)
}
pub type W = std::io::BufWriter<std::io::Stderr>;
#[derive(Debug, PartialEq, Eq)]
pub struct PainterSuspendedState {
previous_prompt_rows_range: RangeInclusive<u16>,
}
#[derive(Debug, Clone, Copy)]
pub struct RightPromptBounds {
pub row: u16,
pub start_col: u16,
pub end_col: u16,
}
#[derive(Debug, Clone)]
pub struct RenderSnapshot {
pub screen_width: u16,
pub screen_height: u16,
pub prompt_start_row: u16,
pub prompt_height: u16,
pub large_buffer: bool,
pub prompt_str_left: String,
pub prompt_indicator: String,
pub before_cursor: String,
pub after_cursor: String,
pub first_buffer_col: u16,
pub menu_active: bool,
pub menu_start_row: Option<u16>,
pub large_buffer_extra_rows_after_prompt: Option<usize>,
pub large_buffer_offset: Option<usize>,
pub right_prompt: Option<RightPromptBounds>,
}
#[derive(Debug, PartialEq, Eq)]
enum PromptRowSelector {
UseExistingPrompt { start_row: u16 },
MakeNewPrompt { new_row: u16 },
}
fn select_prompt_row(
suspended_state: Option<&PainterSuspendedState>,
(column, row): (u16, u16), ) -> PromptRowSelector {
if let Some(painter_state) = suspended_state {
if painter_state.previous_prompt_rows_range.contains(&row) {
let start_row = *painter_state.previous_prompt_rows_range.start();
return PromptRowSelector::UseExistingPrompt { start_row };
} else {
}
}
let new_row = if column > 0 { row + 1 } else { row };
PromptRowSelector::MakeNewPrompt { new_row }
}
pub(crate) struct PromptLayout {
extra_rows: usize,
extra_rows_after_prompt: usize,
large_buffer_offset: Option<usize>,
right_prompt: Option<RightPromptBounds>,
menu_start_row: Option<u16>,
first_buffer_col: u16,
}
pub struct Painter {
stdout: W,
prompt_start_row: u16,
prompt_height: u16,
terminal_size: (u16, u16),
last_required_lines: u16,
large_buffer: bool,
just_resized: bool,
after_cursor_lines: Option<String>,
semantic_markers: Option<Box<dyn SemanticPromptMarkers>>,
pub(crate) last_layout: Option<PromptLayout>,
}
impl Painter {
pub(crate) fn new(stdout: W) -> Self {
Painter {
stdout,
prompt_start_row: 0,
prompt_height: 0,
terminal_size: (0, 0),
last_required_lines: 0,
large_buffer: false,
just_resized: false,
after_cursor_lines: None,
semantic_markers: None,
last_layout: None,
}
}
pub fn screen_height(&self) -> u16 {
self.terminal_size.1
}
pub fn screen_width(&self) -> u16 {
self.terminal_size.0
}
pub fn set_semantic_markers(&mut self, markers: Option<Box<dyn SemanticPromptMarkers>>) {
self.semantic_markers = markers;
}
pub fn semantic_markers(&self) -> Option<&dyn SemanticPromptMarkers> {
self.semantic_markers.as_deref()
}
pub fn remaining_lines_real(&self) -> u16 {
self.screen_height()
.saturating_sub(self.prompt_start_row)
.saturating_sub(self.prompt_height)
}
pub fn remaining_lines(&self) -> u16 {
self.screen_height().saturating_sub(self.prompt_start_row)
}
fn compute_layout(&self, lines: &PromptLines, menu: Option<&ReedlineMenu>) -> PromptLayout {
let screen_width = self.screen_width();
let screen_height = self.screen_height();
let (extra_rows, extra_rows_after_prompt) = if self.large_buffer {
let prompt_lines = lines.prompt_lines_with_wrap(screen_width) as usize;
let prompt_indicator_lines = lines.prompt_indicator.lines().count();
let before_cursor_lines = lines.before_cursor.lines().count();
let total_lines_before =
prompt_lines + prompt_indicator_lines + before_cursor_lines - 1;
let extra = total_lines_before.saturating_sub(screen_height as usize);
(extra, extra.saturating_sub(prompt_lines))
} else {
(0, 0)
};
let large_buffer_offset = if self.large_buffer {
let cursor_distance = lines.distance_from_prompt(screen_width);
menu.and_then(|menu| {
if cursor_distance >= screen_height.saturating_sub(1) {
let rows = lines
.before_cursor
.lines()
.count()
.saturating_sub(extra_rows_after_prompt)
.saturating_sub(menu.min_rows() as usize);
Some(rows)
} else {
None
}
})
} else {
None
};
let right_prompt =
if lines.prompt_str_right.is_empty() || self.large_buffer && extra_rows > 0 {
None
} else {
let prompt_length_right = line_width(&lines.prompt_str_right);
let start_position = screen_width.saturating_sub(prompt_length_right as u16);
let input_width = lines.estimate_right_prompt_line_width(screen_width);
if input_width <= start_position {
let mut row = self.prompt_start_row;
if lines.right_prompt_on_last_line {
row += lines.prompt_lines_with_wrap(screen_width);
}
Some(RightPromptBounds {
row,
start_col: start_position,
end_col: start_position.saturating_add(prompt_length_right as u16),
})
} else {
None
}
};
let menu_start_row = menu.map(|menu| {
let cursor_distance = lines.distance_from_prompt(screen_width);
if cursor_distance >= screen_height.saturating_sub(1) {
screen_height.saturating_sub(menu.min_rows())
} else {
self.prompt_start_row + cursor_distance + 1
}
});
let first_buffer_col = if self.large_buffer && extra_rows_after_prompt > 0 {
0
} else {
let prompt_line = format!("{}{}", lines.prompt_str_left, lines.prompt_indicator);
let last_prompt_line = prompt_line.lines().last().unwrap_or_default();
let width = line_width(last_prompt_line);
if width > u16::MAX as usize {
u16::MAX
} else {
width as u16
}
};
PromptLayout {
extra_rows,
extra_rows_after_prompt,
large_buffer_offset,
right_prompt,
menu_start_row,
first_buffer_col,
}
}
pub fn state_before_suspension(&self) -> PainterSuspendedState {
let start_row = self.prompt_start_row;
let final_row = start_row + self.last_required_lines;
PainterSuspendedState {
previous_prompt_rows_range: start_row..=final_row,
}
}
pub(crate) fn initialize_prompt_position(
&mut self,
suspended_state: Option<&PainterSuspendedState>,
) -> Result<()> {
self.terminal_size = {
let size = terminal::size()?;
if size == (0, 0) {
(80, 24)
} else {
size
}
};
let prompt_selector = select_prompt_row(suspended_state, cursor::position()?);
self.prompt_start_row = match prompt_selector {
PromptRowSelector::UseExistingPrompt { start_row } => start_row,
PromptRowSelector::MakeNewPrompt { new_row } => {
if new_row == self.screen_height() {
self.print_crlf()?;
new_row.saturating_sub(1)
} else {
new_row
}
}
};
Ok(())
}
pub(crate) fn repaint_buffer(
&mut self,
prompt: &dyn Prompt,
lines: &PromptLines,
prompt_mode: PromptEditMode,
menu: Option<&ReedlineMenu>,
use_ansi_coloring: bool,
cursor_config: &Option<CursorConfig>,
) -> Result<()> {
self.stdout.queue(SetAttribute(Attribute::Reset))?;
self.stdout.queue(cursor::Hide)?;
let screen_width = self.screen_width();
let screen_height = self.screen_height();
self.prompt_height = lines.prompt_lines_with_wrap(screen_width) + 1;
let lines_before_cursor = lines.required_lines(screen_width, true, None);
if self.just_resized {
self.prompt_start_row = self
.prompt_start_row
.saturating_sub(lines_before_cursor - 1);
self.just_resized = false;
}
let remaining_lines = self.remaining_lines();
let required_lines = lines.required_lines(screen_width, false, menu);
self.large_buffer = required_lines >= screen_height;
let is_reset = || match cursor::position() {
Ok(position) => position.1 + 1 < self.prompt_start_row,
Err(_) => false,
};
if self.large_buffer || is_reset() {
for _ in 0..screen_height.saturating_sub(lines_before_cursor) {
self.stdout.queue(Print(&coerce_crlf("\n")))?;
}
self.prompt_start_row = 0;
} else if required_lines >= remaining_lines {
let extra = required_lines.saturating_sub(remaining_lines);
self.queue_universal_scroll(extra)?;
self.prompt_start_row = self.prompt_start_row.saturating_sub(extra);
}
self.stdout
.queue(cursor::MoveTo(0, self.prompt_start_row))?
.queue(Clear(ClearType::FromCursorDown))?;
let layout = self.compute_layout(lines, menu);
if self.large_buffer {
self.print_large_buffer(prompt, lines, menu, use_ansi_coloring, &layout)?;
} else {
self.print_small_buffer(prompt, lines, menu, use_ansi_coloring, &layout)?;
}
self.last_layout = Some(layout);
self.last_required_lines = required_lines;
self.after_cursor_lines = if !lines.after_cursor.is_empty() {
Some(lines.after_cursor.to_string())
} else {
None
};
self.stdout.queue(RestorePosition)?;
if let Some(shapes) = cursor_config {
let shape = match &prompt_mode {
PromptEditMode::Emacs => shapes.emacs,
PromptEditMode::Vi(PromptViMode::Insert) => shapes.vi_insert,
PromptEditMode::Vi(PromptViMode::Normal) => shapes.vi_normal,
_ => None,
};
if let Some(shape) = shape {
self.stdout.queue(shape)?;
}
}
self.stdout.queue(cursor::Show)?;
self.stdout.flush()
}
pub(crate) fn render_snapshot(
&self,
lines: &PromptLines,
menu: Option<&ReedlineMenu>,
raw_before: &str,
raw_after: &str,
layout: &PromptLayout,
) -> RenderSnapshot {
let large_buffer_extra_rows_after_prompt = if self.large_buffer {
Some(layout.extra_rows_after_prompt)
} else {
None
};
let large_buffer_offset = layout.large_buffer_offset;
RenderSnapshot {
screen_width: self.screen_width(),
screen_height: self.screen_height(),
prompt_start_row: self.prompt_start_row,
prompt_height: self.prompt_height,
large_buffer: self.large_buffer,
prompt_str_left: lines.prompt_str_left.to_string(),
prompt_indicator: lines.prompt_indicator.to_string(),
before_cursor: raw_before.to_string(),
after_cursor: raw_after.to_string(),
first_buffer_col: layout.first_buffer_col,
menu_active: menu.is_some(),
menu_start_row: layout.menu_start_row,
large_buffer_extra_rows_after_prompt,
large_buffer_offset,
right_prompt: layout.right_prompt,
}
}
pub(crate) fn screen_to_buffer_offset(
&self,
snapshot: &RenderSnapshot,
column: u16,
row: u16,
) -> Option<usize> {
if row < snapshot.prompt_start_row {
return None;
}
if snapshot.menu_active {
if let Some(menu_start_row) = snapshot.menu_start_row {
if row >= menu_start_row {
return None;
}
}
}
if let Some(rp) = &snapshot.right_prompt {
if row == rp.row && column >= rp.start_col && column < rp.end_col {
return None;
}
}
let screen_width = snapshot.screen_width;
let target_row = row.saturating_sub(snapshot.prompt_start_row);
let buffer_start_row = if snapshot.large_buffer
&& snapshot.large_buffer_extra_rows_after_prompt.unwrap_or(0) > 0
{
0
} else {
snapshot.prompt_height.saturating_sub(1)
};
if target_row < buffer_start_row {
return None;
}
let (before_start, before_end) = if snapshot.large_buffer {
skip_buffer_lines_range(
&snapshot.before_cursor,
snapshot.large_buffer_extra_rows_after_prompt.unwrap_or(0),
snapshot.large_buffer_offset,
)
} else {
(0, snapshot.before_cursor.len())
};
let before_visible = &snapshot.before_cursor[before_start..before_end];
let full_before_visible = before_start == 0 && before_end == snapshot.before_cursor.len();
let (after_start, after_end) = if snapshot.large_buffer {
if snapshot.menu_active {
let end = snapshot
.after_cursor
.find('\n')
.unwrap_or(snapshot.after_cursor.len());
(0, end)
} else {
let cursor_distance = estimate_required_lines(
&format!(
"{}{}{}",
snapshot.prompt_str_left, snapshot.prompt_indicator, snapshot.before_cursor
),
screen_width,
)
.saturating_sub(1) as u16;
let remaining_lines = snapshot.screen_height.saturating_sub(cursor_distance);
let offset = remaining_lines.saturating_sub(1) as usize;
skip_buffer_lines_range(&snapshot.after_cursor, 0, Some(offset))
}
} else {
(0, snapshot.after_cursor.len())
};
let after_visible = &snapshot.after_cursor[after_start..after_end];
let full_after_visible = after_start == 0 && after_end == snapshot.after_cursor.len();
let full_buffer_visible = full_before_visible && full_after_visible;
let mut current_row = buffer_start_row;
let mut current_col = if current_row == buffer_start_row {
snapshot.first_buffer_col
} else {
0
};
let mut check_segment = |segment: &str, base_offset: usize| -> Option<usize> {
for (index, grapheme) in segment.grapheme_indices(true) {
if grapheme == "\n" {
current_row = current_row.saturating_add(1);
current_col = 0;
continue;
}
let width = grapheme.width().max(1) as u16;
if current_col.saturating_add(width) > screen_width {
current_row = current_row.saturating_add(1);
current_col = 0;
}
if current_row == target_row
&& column >= current_col
&& column < current_col.saturating_add(width)
{
return Some(base_offset + index);
}
current_col = current_col.saturating_add(width);
}
None
};
if let Some(offset) = check_segment(before_visible, before_start) {
return Some(offset);
}
let after_base = snapshot.before_cursor.len().saturating_add(after_start);
if let Some(offset) = check_segment(after_visible, after_base) {
return Some(offset);
}
if full_buffer_visible && target_row == current_row && column >= current_col {
return Some(snapshot.before_cursor.len() + snapshot.after_cursor.len());
}
None
}
fn print_right_prompt(&mut self, lines: &PromptLines, layout: &PromptLayout) -> Result<()> {
let Some(rp) = &layout.right_prompt else {
return Ok(());
};
self.stdout
.queue(SavePosition)?
.queue(cursor::MoveTo(rp.start_col, rp.row))?;
if let Some(markers) = &self.semantic_markers {
self.stdout
.queue(Print(markers.prompt_start(PromptKind::Right)))?;
}
self.stdout
.queue(Print(&coerce_crlf(&lines.prompt_str_right)))?
.queue(RestorePosition)?;
Ok(())
}
fn print_menu(
&mut self,
menu: &dyn Menu,
use_ansi_coloring: bool,
layout: &PromptLayout,
) -> Result<()> {
let starting_row = layout.menu_start_row.unwrap_or(0);
let remaining_lines = self.screen_height().saturating_sub(starting_row);
let menu_string = menu.menu_string(remaining_lines, use_ansi_coloring);
self.stdout
.queue(cursor::MoveTo(0, starting_row))?
.queue(Clear(ClearType::FromCursorDown))?
.queue(Print(menu_string.trim_end_matches('\n')))?;
Ok(())
}
fn print_small_buffer(
&mut self,
prompt: &dyn Prompt,
lines: &PromptLines,
menu: Option<&ReedlineMenu>,
use_ansi_coloring: bool,
layout: &PromptLayout,
) -> Result<()> {
if let Some(markers) = &self.semantic_markers {
self.stdout
.queue(Print(markers.prompt_start(PromptKind::Primary)))?;
}
if use_ansi_coloring {
self.stdout
.queue(SetForegroundColor(prompt.get_prompt_color()))?;
}
self.stdout
.queue(Print(&coerce_crlf(&lines.prompt_str_left)))?;
if use_ansi_coloring {
self.stdout
.queue(SetForegroundColor(prompt.get_indicator_color()))?;
}
self.stdout
.queue(Print(&coerce_crlf(&lines.prompt_indicator)))?;
if use_ansi_coloring {
self.stdout
.queue(SetForegroundColor(prompt.get_prompt_right_color()))?;
}
self.print_right_prompt(lines, layout)?;
if let Some(markers) = &self.semantic_markers {
self.stdout.queue(Print(markers.command_input_start()))?;
}
if use_ansi_coloring {
self.stdout
.queue(SetAttribute(Attribute::Reset))?
.queue(ResetColor)?;
}
self.stdout
.queue(Print(&lines.before_cursor))?
.queue(SavePosition)?
.queue(Print(&lines.after_cursor))?;
if let Some(menu) = menu {
self.print_menu(menu, use_ansi_coloring, layout)?;
} else {
self.stdout.queue(Print(&lines.hint))?;
}
Ok(())
}
fn print_large_buffer(
&mut self,
prompt: &dyn Prompt,
lines: &PromptLines,
menu: Option<&ReedlineMenu>,
use_ansi_coloring: bool,
layout: &PromptLayout,
) -> Result<()> {
let screen_width = self.screen_width();
let screen_height = self.screen_height();
let cursor_distance = lines.distance_from_prompt(screen_width);
let remaining_lines = screen_height.saturating_sub(cursor_distance);
let extra_rows = layout.extra_rows;
let extra_rows_after_prompt = layout.extra_rows_after_prompt;
if extra_rows == 0 {
if let Some(markers) = &self.semantic_markers {
self.stdout
.queue(Print(markers.prompt_start(PromptKind::Primary)))?;
}
}
if use_ansi_coloring {
self.stdout
.queue(SetForegroundColor(prompt.get_prompt_color()))?;
}
let prompt_skipped = skip_buffer_lines(&lines.prompt_str_left, extra_rows, None);
self.stdout.queue(Print(&coerce_crlf(prompt_skipped)))?;
if extra_rows == 0 {
if use_ansi_coloring {
self.stdout
.queue(SetForegroundColor(prompt.get_prompt_right_color()))?;
}
self.print_right_prompt(lines, layout)?;
}
if use_ansi_coloring {
self.stdout
.queue(SetForegroundColor(prompt.get_indicator_color()))?;
}
let indicator_skipped =
skip_buffer_lines(&lines.prompt_indicator, extra_rows_after_prompt, None);
self.stdout.queue(Print(&coerce_crlf(indicator_skipped)))?;
if let Some(markers) = &self.semantic_markers {
self.stdout.queue(Print(markers.command_input_start()))?;
}
if use_ansi_coloring {
self.stdout.queue(ResetColor)?;
}
let before_cursor_skipped = skip_buffer_lines(
&lines.before_cursor,
extra_rows_after_prompt,
layout.large_buffer_offset,
);
self.stdout.queue(Print(before_cursor_skipped))?;
self.stdout.queue(SavePosition)?;
if let Some(menu) = menu {
if let Some(newline) = lines.after_cursor.find('\n') {
self.stdout.queue(Print(&lines.after_cursor[0..newline]))?;
} else {
self.stdout.queue(Print(&lines.after_cursor))?;
}
self.print_menu(menu, use_ansi_coloring, layout)?;
} else {
let offset = remaining_lines.saturating_sub(1) as usize;
let after_cursor_skipped = skip_buffer_lines(&lines.after_cursor, 0, Some(offset));
self.stdout.queue(Print(after_cursor_skipped))?;
let hint_skipped = skip_buffer_lines(&lines.hint, 0, Some(offset));
self.stdout.queue(Print(hint_skipped))?;
}
Ok(())
}
pub(crate) fn handle_resize(&mut self, width: u16, height: u16) {
self.terminal_size = (width, height);
#[cfg(not(test))]
{
if let Ok(position) = cursor::position() {
self.prompt_start_row = position.1;
self.just_resized = true;
}
}
}
pub(crate) fn paint_line(&mut self, line: &str) -> Result<()> {
self.stdout.queue(Print(line))?.queue(Print("\r\n"))?;
self.stdout.flush()
}
pub(crate) fn print_crlf(&mut self) -> Result<()> {
self.stdout.queue(Print("\r\n"))?;
self.stdout.flush()
}
pub(crate) fn clear_screen(&mut self) -> Result<()> {
self.stdout
.queue(Clear(ClearType::All))?
.queue(MoveTo(0, 0))?
.flush()?;
self.initialize_prompt_position(None)
}
pub(crate) fn clear_scrollback(&mut self) -> Result<()> {
self.stdout
.queue(Clear(ClearType::All))?
.queue(Clear(ClearType::Purge))?
.queue(MoveTo(0, 0))?
.flush()?;
self.initialize_prompt_position(None)
}
pub(crate) fn move_cursor_to_end(&mut self) -> Result<()> {
if let Some(after_cursor) = &self.after_cursor_lines {
self.stdout
.queue(Clear(ClearType::FromCursorDown))?
.queue(Print(after_cursor))?;
}
self.print_crlf()
}
#[cfg(feature = "external_printer")]
pub(crate) fn print_external_message(
&mut self,
messages: Vec<String>,
line_buffer: &LineBuffer,
prompt: &dyn Prompt,
) -> Result<()> {
let prompt_len = prompt.render_prompt_right().len() + 3;
let mut buffer_num_lines = 0_u16;
for (i, line) in line_buffer.get_buffer().lines().enumerate() {
let screen_lines = match i {
0 => {
let first_line_len = line.len() + prompt_len;
((first_line_len as u16) / (self.screen_width())) + 1
}
_ => {
((line.len() as u16) / self.screen_width()) + 1
}
};
buffer_num_lines = buffer_num_lines.saturating_add(screen_lines);
}
if buffer_num_lines > 1 {
self.stdout.queue(MoveUp(buffer_num_lines - 1))?;
}
let erase_line = format!("\r{}\r", " ".repeat(self.screen_width().into()));
for line in messages {
self.stdout.queue(Print(&erase_line))?;
self.stdout.queue(Print(line))?.queue(Print("\r\n"))?;
let new_start = self.prompt_start_row.saturating_add(1);
let height = self.screen_height();
if new_start >= height {
self.prompt_start_row = height - 1;
} else {
self.prompt_start_row = new_start;
}
}
Ok(())
}
fn queue_universal_scroll(&mut self, num: u16) -> Result<()> {
self.stdout.queue(MoveTo(0, self.screen_height() - 1))?;
for _ in 0..num {
self.stdout.queue(Print(&coerce_crlf("\n")))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::PromptHistorySearch;
use pretty_assertions::assert_eq;
use std::borrow::Cow;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq, Eq)]
enum MarkerCall {
PromptPrimary,
PromptRight,
CommandInput,
}
struct RecordingMarkers {
calls: Arc<Mutex<Vec<MarkerCall>>>,
}
impl SemanticPromptMarkers for RecordingMarkers {
fn prompt_start(&self, kind: PromptKind) -> Cow<'_, str> {
let mut calls = self.calls.lock().expect("marker lock poisoned");
match kind {
PromptKind::Primary => calls.push(MarkerCall::PromptPrimary),
PromptKind::Right => calls.push(MarkerCall::PromptRight),
PromptKind::Secondary => {}
}
Cow::Borrowed("")
}
fn command_input_start(&self) -> Cow<'_, str> {
let mut calls = self.calls.lock().expect("marker lock poisoned");
calls.push(MarkerCall::CommandInput);
Cow::Borrowed("")
}
}
struct TestPrompt;
impl Prompt for TestPrompt {
fn render_prompt_left(&self) -> Cow<'_, str> {
"> ".into()
}
fn render_prompt_right(&self) -> Cow<'_, str> {
"RP".into()
}
fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> {
"".into()
}
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
"".into()
}
fn render_prompt_history_search_indicator(
&self,
_history_search: PromptHistorySearch,
) -> Cow<'_, str> {
"".into()
}
}
#[test]
fn test_skip_lines() {
let string = "sentence1\nsentence2\nsentence3\n";
assert_eq!(skip_buffer_lines(string, 1, None), "sentence2\nsentence3");
assert_eq!(skip_buffer_lines(string, 2, None), "sentence3");
assert_eq!(skip_buffer_lines(string, 3, None), "");
assert_eq!(skip_buffer_lines(string, 4, None), "");
}
#[test]
fn test_skip_lines_no_newline() {
let string = "sentence1";
assert_eq!(skip_buffer_lines(string, 0, None), "sentence1");
assert_eq!(skip_buffer_lines(string, 1, None), "");
}
#[test]
fn test_skip_lines_with_limit() {
let string = "sentence1\nsentence2\nsentence3\nsentence4\nsentence5";
assert_eq!(
skip_buffer_lines(string, 1, Some(1)),
"sentence2\nsentence3",
);
assert_eq!(
skip_buffer_lines(string, 1, Some(2)),
"sentence2\nsentence3\nsentence4",
);
assert_eq!(
skip_buffer_lines(string, 2, Some(1)),
"sentence3\nsentence4",
);
assert_eq!(
skip_buffer_lines(string, 1, Some(10)),
"sentence2\nsentence3\nsentence4\nsentence5",
);
assert_eq!(
skip_buffer_lines(string, 0, Some(1)),
"sentence1\nsentence2",
);
assert_eq!(skip_buffer_lines(string, 0, Some(0)), "sentence1",);
assert_eq!(skip_buffer_lines(string, 1, Some(0)), "sentence2",);
}
#[test]
fn test_select_new_prompt_with_no_state_no_output() {
assert_eq!(
select_prompt_row(None, (0, 12)),
PromptRowSelector::MakeNewPrompt { new_row: 12 }
);
}
#[test]
fn test_select_new_prompt_with_no_state_but_output() {
assert_eq!(
select_prompt_row(None, (3, 12)),
PromptRowSelector::MakeNewPrompt { new_row: 13 }
);
}
#[test]
fn test_select_existing_prompt() {
let state = PainterSuspendedState {
previous_prompt_rows_range: 11..=13,
};
assert_eq!(
select_prompt_row(Some(&state), (0, 12)),
PromptRowSelector::UseExistingPrompt { start_row: 11 }
);
assert_eq!(
select_prompt_row(Some(&state), (3, 12)),
PromptRowSelector::UseExistingPrompt { start_row: 11 }
);
}
fn base_snapshot() -> RenderSnapshot {
RenderSnapshot {
screen_width: 20,
screen_height: 10,
prompt_start_row: 0,
prompt_height: 1,
large_buffer: false,
prompt_str_left: "> ".to_string(),
prompt_indicator: "".to_string(),
before_cursor: "".to_string(),
after_cursor: "".to_string(),
first_buffer_col: 2,
menu_active: false,
menu_start_row: None,
large_buffer_extra_rows_after_prompt: None,
large_buffer_offset: None,
right_prompt: None,
}
}
#[test]
fn test_screen_to_buffer_simple() {
let mut snapshot = base_snapshot();
snapshot.before_cursor = "hello world".to_string();
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 2, 0), Some(0));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 3, 0), Some(1));
}
#[test]
fn test_clicks_past_eol_clamps() {
let mut snapshot = base_snapshot();
snapshot.before_cursor = "hi".to_string();
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 10, 0), Some(2));
}
#[test]
fn test_wrapped_line_mapping() {
let mut snapshot = base_snapshot();
snapshot.screen_width = 5;
snapshot.before_cursor = "abcdef".to_string();
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 1, 1), Some(4));
}
#[test]
fn test_multiline_mapping() {
let mut snapshot = base_snapshot();
snapshot.before_cursor = "ab\ncd".to_string();
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 1, 1), Some(4));
}
#[test]
fn test_large_buffer_skips_lines() {
let mut snapshot = base_snapshot();
snapshot.large_buffer = true;
snapshot.first_buffer_col = 0;
snapshot.before_cursor = "line1\nline2\nline3".to_string();
snapshot.large_buffer_extra_rows_after_prompt = Some(1);
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 0, 0), Some(6));
}
#[test]
fn test_click_in_right_prompt_ignored() {
let mut snapshot = base_snapshot();
snapshot.before_cursor = "hello".to_string();
snapshot.right_prompt = Some(RightPromptBounds {
row: 0,
start_col: 10,
end_col: 12,
});
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 10, 0), None);
}
#[test]
fn test_click_in_menu_ignored() {
let mut snapshot = base_snapshot();
snapshot.menu_active = true;
snapshot.menu_start_row = Some(2);
let painter = Painter::new(W::new(std::io::stderr()));
assert_eq!(painter.screen_to_buffer_offset(&snapshot, 0, 2), None);
}
fn make_painter(width: u16, height: u16, large_buffer: bool) -> Painter {
let mut p = Painter::new(W::new(std::io::stderr()));
p.terminal_size = (width, height);
p.prompt_start_row = 0;
p.prompt_height = 1;
p.large_buffer = large_buffer;
p
}
fn make_lines<'a>(
left: &'a str,
indicator: &'a str,
right: &'a str,
before: &'a str,
after: &'a str,
) -> PromptLines<'a> {
PromptLines {
prompt_str_left: Cow::Borrowed(left),
prompt_str_right: Cow::Borrowed(right),
prompt_indicator: Cow::Borrowed(indicator),
before_cursor: Cow::Borrowed(before),
after_cursor: Cow::Borrowed(after),
hint: Cow::Borrowed(""),
right_prompt_on_last_line: false,
}
}
#[test]
fn test_layout_small_buffer_defaults() {
let painter = make_painter(20, 10, false);
let lines = make_lines("> ", "", "", "hello", "");
let layout = painter.compute_layout(&lines, None);
assert_eq!(layout.extra_rows, 0);
assert_eq!(layout.extra_rows_after_prompt, 0);
assert_eq!(layout.large_buffer_offset, None);
assert_eq!(layout.first_buffer_col, 2); assert_eq!(layout.menu_start_row, None);
}
#[test]
fn test_layout_right_prompt_rendered() {
let painter = make_painter(40, 10, false);
let lines = make_lines("> ", "", "RP", "hi", "");
let layout = painter.compute_layout(&lines, None);
let rp = layout
.right_prompt
.expect("right prompt should be rendered");
assert_eq!(rp.row, 0);
assert_eq!(rp.start_col, 38); assert_eq!(rp.end_col, 40);
}
#[test]
fn test_layout_right_prompt_hidden_when_input_too_wide() {
let painter = make_painter(10, 10, false);
let lines = make_lines("> ", "", "RP", "12345678", "");
let layout = painter.compute_layout(&lines, None);
assert!(layout.right_prompt.is_none());
}
#[test]
fn test_layout_large_buffer_extra_rows() {
let painter = make_painter(20, 5, true);
let lines = make_lines("> ", "", "", "l1\nl2\nl3\nl4\nl5\nl6\nl7", "");
let layout = painter.compute_layout(&lines, None);
assert_eq!(layout.extra_rows, 1);
assert_eq!(layout.extra_rows_after_prompt, 1);
assert_eq!(layout.first_buffer_col, 0); }
#[test]
fn test_layout_right_prompt_suppressed_in_large_buffer() {
let painter = make_painter(20, 5, true);
let lines = make_lines("> ", "", "RP", "l1\nl2\nl3\nl4\nl5\nl6\nl7", "");
let layout = painter.compute_layout(&lines, None);
assert!(layout.extra_rows > 0);
assert!(layout.right_prompt.is_none());
}
#[test]
fn test_layout_large_buffer_no_scroll_keeps_right_prompt() {
let painter = make_painter(20, 10, true);
let lines = make_lines("> ", "", "RP", "short", "");
let layout = painter.compute_layout(&lines, None);
assert_eq!(layout.extra_rows, 0);
assert!(layout.right_prompt.is_some());
}
#[test]
fn test_layout_first_buffer_col_with_multiline_prompt() {
let painter = make_painter(20, 10, false);
let lines = make_lines("line1\n$ ", "", "", "hello", "");
let layout = painter.compute_layout(&lines, None);
assert_eq!(layout.first_buffer_col, 2);
}
#[test]
fn test_prompt_marker_order_in_small_buffer() {
let calls = Arc::new(Mutex::new(Vec::new()));
let markers = RecordingMarkers {
calls: Arc::clone(&calls),
};
let mut painter = Painter::new(W::new(std::io::stderr()));
painter.terminal_size = (20, 10);
painter.prompt_start_row = 0;
painter.prompt_height = 1;
painter.set_semantic_markers(Some(Box::new(markers)));
let prompt = TestPrompt;
let lines = PromptLines::new(&prompt, PromptEditMode::Default, None, "", "", "");
let layout = painter.compute_layout(&lines, None);
painter
.print_small_buffer(&prompt, &lines, None, false, &layout)
.expect("print_small_buffer failed");
let recorded = calls.lock().expect("marker lock poisoned").clone();
assert_eq!(
recorded,
vec![
MarkerCall::PromptPrimary,
MarkerCall::PromptRight,
MarkerCall::CommandInput
]
);
}
}