fish-shell/src/screen.rs
Mahmoud Al-Qudsi 2ecbc56de9 Change MainThread<T> abstraction
Don't force the internal use of `RefCell<T>`, let the caller place that into
`MainThread<>` manually. This lets us remove the reference to `MainThread<>`
from the definition of `Screen` again and reduces the number of
`assert_is_main_thread()` calls.
2024-03-01 19:42:43 -06:00

1842 lines
71 KiB
Rust
Raw Blame History

//! High level library for handling the terminal screen
//!
//! The screen library allows the interactive reader to write its output to screen efficiently by
//! keeping an internal representation of the current screen contents and trying to find a reasonably
//! efficient way for transforming that to the desired screen content.
//!
//! The current implementation is less smart than ncurses allows and can not for example move blocks
//! of text around to handle text insertion.
use crate::pager::{PageRendering, Pager};
use std::cell::RefCell;
use std::collections::LinkedList;
use std::ffi::{CStr, CString};
use std::io::Write;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
use libc::{ONLCR, STDERR_FILENO, STDOUT_FILENO};
use crate::common::{
fish_reserved_codepoint, get_ellipsis_char, get_omitted_newline_str, get_omitted_newline_width,
has_working_tty_timestamps, shell_modes, str2wcstring, wcs2string, write_loop, ScopeGuard,
ScopeGuarding,
};
use crate::curses::{term, tparm0, tparm1};
use crate::env::{Environment, TERM_HAS_XN};
use crate::fallback::fish_wcwidth;
use crate::flog::FLOGF;
#[allow(unused_imports)]
use crate::future::IsSomeAnd;
use crate::global_safety::RelaxedAtomicBool;
use crate::highlight::HighlightColorResolver;
use crate::highlight::HighlightSpec;
use crate::output::Outputter;
use crate::termsize::{termsize_last, Termsize};
use crate::wchar::prelude::*;
use crate::wcstringutil::string_prefixes_string;
#[derive(Clone, Default)]
pub struct HighlightedChar {
highlight: HighlightSpec,
character: char,
}
/// A class representing a single line of a screen.
#[derive(Clone, Default)]
pub struct Line {
/// A pair of a character, and the color with which to draw it.
pub text: Vec<HighlightedChar>,
pub is_soft_wrapped: bool,
pub indentation: usize,
}
impl Line {
pub fn new() -> Self {
Default::default()
}
/// Clear the line's contents.
fn clear(&mut self) {
self.text.clear();
}
/// Append a single character \p txt to the line with color \p c.
pub fn append(&mut self, character: char, highlight: HighlightSpec) {
self.text.push(HighlightedChar {
highlight,
character: rendered_character(character),
})
}
/// Append a nul-terminated string \p txt to the line, giving each character \p color.
pub fn append_str(&mut self, txt: &wstr, highlight: HighlightSpec) {
for c in txt.chars() {
self.append(c, highlight);
}
}
/// \return the number of characters.
pub fn len(&self) -> usize {
self.text.len()
}
/// \return the character at a char index.
pub fn char_at(&self, idx: usize) -> char {
self.text[idx].character
}
/// \return the color at a char index.
pub fn color_at(&self, idx: usize) -> HighlightSpec {
self.text[idx].highlight
}
/// Append the contents of \p line to this line.
pub fn append_line(&mut self, line: &Line) {
self.text.extend_from_slice(&line.text);
}
/// \return the width of this line, counting up to no more than \p max characters.
/// This follows fish_wcswidth() semantics, except that characters whose width would be -1 are
/// treated as 0.
pub fn wcswidth_min_0(&self, max: usize /* = usize::MAX */) -> usize {
let mut result: usize = 0;
for c in &self.text[..max.min(self.text.len())] {
result += wcwidth_rendered(c.character);
}
result
}
}
/// Where the cursor is in (x, y) coordinates.
#[derive(Clone, Copy, Default)]
pub struct Cursor {
x: usize,
y: usize,
}
/// A class representing screen contents.
#[derive(Clone, Default)]
pub struct ScreenData {
line_datas: Vec<Line>,
/// The width of the screen in this rendering.
/// -1 if not set, i.e. we have not rendered before.
screen_width: Option<usize>,
cursor: Cursor,
}
impl ScreenData {
pub fn add_line(&mut self) -> &mut Line {
self.line_datas.push(Line::new());
self.line_datas.last_mut().unwrap()
}
pub fn resize(&mut self, size: usize) {
self.line_datas.resize(size, Default::default())
}
pub fn create_line(&mut self, idx: usize) -> &mut Line {
if idx >= self.line_datas.len() {
self.line_datas.resize(idx + 1, Default::default())
}
self.line_mut(idx)
}
pub fn insert_line_at_index(&mut self, idx: usize) -> &mut Line {
assert!(idx <= self.line_datas.len());
self.line_datas.insert(idx, Default::default());
&mut self.line_datas[idx]
}
pub fn line(&self, idx: usize) -> &Line {
&self.line_datas[idx]
}
pub fn line_mut(&mut self, idx: usize) -> &mut Line {
&mut self.line_datas[idx]
}
pub fn line_count(&self) -> usize {
self.line_datas.len()
}
pub fn append_lines(&mut self, d: &ScreenData) {
self.line_datas.extend_from_slice(&d.line_datas);
}
pub fn is_empty(&self) -> bool {
self.line_datas.is_empty()
}
}
/// The class representing the current and desired screen contents.
pub struct Screen {
/// Whether the last-drawn autosuggestion (if any) is truncated, or hidden entirely.
pub autosuggestion_is_truncated: bool,
/// Receiver for our output.
outp: &'static RefCell<Outputter>,
/// The internal representation of the desired screen contents.
desired: ScreenData,
/// The internal representation of the actual screen contents.
actual: ScreenData,
/// A string containing the prompt which was last printed to the screen.
actual_left_prompt: WString,
/// Last right prompt width.
last_right_prompt_width: usize,
/// If we support soft wrapping, we can output to this location without any cursor motion.
soft_wrap_location: Option<Cursor>,
/// This flag is set to true when there is reason to suspect that the parts of the screen lines
/// where the actual content is not filled in may be non-empty. This means that a clr_eol
/// command has to be sent to the terminal at the end of each line, including
/// actual_lines_before_reset.
need_clear_lines: bool,
/// Whether there may be yet more content after the lines, and we issue a clr_eos if possible.
need_clear_screen: bool,
/// If we need to clear, this is how many lines the actual screen had, before we reset it. This
/// is used when resizing the window larger: if the cursor jumps to the line above, we need to
/// remember to clear the subsequent lines.
actual_lines_before_reset: usize,
/// These status buffers are used to check if any output has occurred other than from fish's
/// main loop, in which case we need to redraw.
prev_buff_1: libc::stat,
prev_buff_2: libc::stat,
}
impl Screen {
pub fn new() -> Self {
Self {
outp: Outputter::stdoutput(),
autosuggestion_is_truncated: Default::default(),
desired: Default::default(),
actual: Default::default(),
actual_left_prompt: Default::default(),
last_right_prompt_width: Default::default(),
soft_wrap_location: Default::default(),
need_clear_lines: Default::default(),
need_clear_screen: Default::default(),
actual_lines_before_reset: Default::default(),
prev_buff_1: unsafe { std::mem::zeroed() },
prev_buff_2: unsafe { std::mem::zeroed() },
}
}
/// This is the main function for the screen output library. It is used to define the desired
/// contents of the screen. The screen command will use its knowledge of the current contents of
/// the screen in order to render the desired output using as few terminal commands as possible.
///
/// \param left_prompt the prompt to prepend to the command line
/// \param right_prompt the right prompt, or NULL if none
/// \param commandline the command line
/// \param explicit_len the number of characters of the "explicit" (non-autosuggestion) portion
/// of the command line \param colors the colors to use for the commanad line \param indent the
/// indent to use for the command line \param cursor_pos where the cursor is \param pager the
/// pager to render below the command line \param page_rendering to cache the current pager view
/// \param cursor_is_within_pager whether the position is within the pager line (first line)
pub fn write(
&mut self,
left_prompt: &wstr,
right_prompt: &wstr,
commandline: &wstr,
explicit_len: usize,
colors: &[HighlightSpec],
indent: &[i32],
cursor_pos: usize,
vars: &dyn Environment,
pager: &mut Pager,
page_rendering: &mut PageRendering,
cursor_is_within_pager: bool,
) {
let curr_termsize = termsize_last();
let screen_width = curr_termsize.width;
static REPAINTS: AtomicU32 = AtomicU32::new(0);
FLOGF!(
screen,
"Repaint %u",
1 + REPAINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let mut cursor_arr = Cursor::default();
// Turn the command line into the explicit portion and the autosuggestion.
let (explicit_command_line, autosuggestion) = commandline.split_at(explicit_len);
// If we are using a dumb terminal, don't try any fancy stuff, just print out the text.
// right_prompt not supported.
if is_dumb() {
let prompt_narrow = wcs2string(left_prompt);
let command_line_narrow = wcs2string(explicit_command_line);
let _ = write_loop(&STDOUT_FILENO, b"\r");
let _ = write_loop(&STDOUT_FILENO, &prompt_narrow);
let _ = write_loop(&STDOUT_FILENO, &command_line_narrow);
return;
}
self.check_status();
// Completely ignore impossibly small screens.
if screen_width < 4 {
return;
}
let screen_width = usize::try_from(screen_width).unwrap();
// Compute a layout.
let layout = compute_layout(
screen_width,
left_prompt,
right_prompt,
explicit_command_line,
autosuggestion,
);
// Determine whether, if we have an autosuggestion, it was truncated.
self.autosuggestion_is_truncated =
!autosuggestion.is_empty() && autosuggestion != layout.autosuggestion;
// Clear the desired screen and set its width.
self.desired.screen_width = Some(screen_width);
self.desired.resize(0);
self.desired.cursor.x = 0;
self.desired.cursor.y = 0;
// Append spaces for the left prompt.
for _ in 0..layout.left_prompt_space {
self.desired_append_char(' ', HighlightSpec::new(), 0, layout.left_prompt_space, 1);
}
// If overflowing, give the prompt its own line to improve the situation.
let first_line_prompt_space = layout.left_prompt_space;
// Reconstruct the command line.
let effective_commandline = explicit_command_line.to_owned() + &layout.autosuggestion[..];
// Output the command line.
let mut i = 0;
while i < effective_commandline.len() {
// Grab the current cursor's x,y position if this character matches the cursor's offset.
if !cursor_is_within_pager && i == cursor_pos {
cursor_arr = self.desired.cursor;
}
self.desired_append_char(
effective_commandline.as_char_slice()[i],
colors[i],
usize::try_from(indent[i]).unwrap(),
first_line_prompt_space,
wcwidth_rendered(effective_commandline.as_char_slice()[i]),
);
i += 1;
}
// Cursor may have been at the end too.
if !cursor_is_within_pager && i == cursor_pos {
cursor_arr = self.desired.cursor;
}
let full_line_count = self.desired.cursor.y + 1;
// Now that we've output everything, set the cursor to the position that we saved in the loop
// above.
self.desired.cursor = cursor_arr;
if cursor_is_within_pager {
self.desired.cursor.x = cursor_pos;
self.desired.cursor.y = self.desired.line_count();
}
// Re-render our completions page if necessary. Limit the term size of the pager to the true
// term size, minus the number of lines consumed by our string.
pager.set_term_size(&Termsize::new(
std::cmp::max(1, curr_termsize.width),
std::cmp::max(
1,
curr_termsize
.height
.saturating_sub_unsigned(full_line_count),
),
));
pager.update_rendering(page_rendering);
// Append pager_data (none if empty).
self.desired.append_lines(&page_rendering.screen_data);
self.update(&layout.left_prompt, &layout.right_prompt, vars);
self.save_status();
}
/// Resets the screen buffer's internal knowledge about the contents of the screen,
/// optionally repainting the prompt as well.
/// This function assumes that the current line is still valid.
pub fn reset_line(&mut self, repaint_prompt: bool /* = false */) {
// Remember how many lines we had output to, so we can clear the remaining lines in the next
// call to s_update. This prevents leaving junk underneath the cursor when resizing a window
// wider such that it reduces our desired line count.
self.actual_lines_before_reset =
std::cmp::max(self.actual_lines_before_reset, self.actual.line_count());
if repaint_prompt {
// If the prompt is multi-line, we need to move up to the prompt's initial line. We do this
// by lying to ourselves and claiming that we're really below what we consider "line 0"
// (which is the last line of the prompt). This will cause us to move up to try to get back
// to line 0, but really we're getting back to the initial line of the prompt.
let prompt_line_count = calc_prompt_lines(&self.actual_left_prompt);
self.actual.cursor.y += prompt_line_count.checked_sub(1).unwrap();
self.actual_left_prompt.clear();
}
self.actual.resize(0);
self.need_clear_lines = true;
// This should prevent resetting the cursor position during the next repaint.
let _ = write_loop(&STDOUT_FILENO, b"\r");
self.actual.cursor.x = 0;
self.save_status();
}
/// Resets the screen buffer's internal knowledge about the contents of the screen,
/// abandoning the current line and going to the next line.
/// If clear_to_eos is set,
/// The screen width must be provided for the PROMPT_SP hack.
pub fn reset_abandoning_line(&mut self, screen_width: usize) {
self.actual.cursor.y = 0;
self.actual.resize(0);
self.actual_left_prompt.clear();
self.need_clear_lines = true;
// Do the PROMPT_SP hack.
let mut abandon_line_string = WString::with_capacity(screen_width + 32);
// Don't need to check for fish_wcwidth errors; this is done when setting up
// omitted_newline_char in common.cpp.
let non_space_width = get_omitted_newline_width();
let term = term();
let term = term.as_ref();
// We do `>` rather than `>=` because the code below might require one extra space.
if screen_width > non_space_width {
let mut justgrey = true;
let add = |abandon_line_string: &mut WString, s: Option<CString>| {
let Some(s) = s else {
return false;
};
abandon_line_string.push_utfstr(&str2wcstring(s.as_bytes()));
true
};
if let Some(enter_dim_mode) = term.and_then(|term| term.enter_dim_mode.as_ref()) {
if add(&mut abandon_line_string, tparm0(enter_dim_mode)) {
// Use dim if they have it, so the color will be based on their actual normal
// color and the background of the terminal.
justgrey = false;
}
}
if let (true, Some(set_a_foreground)) = (
justgrey,
term.and_then(|term| term.set_a_foreground.as_ref()),
) {
let max_colors = term.unwrap().max_colors.unwrap_or_default();
if max_colors >= 238 {
// draw the string in a particular grey
add(&mut abandon_line_string, tparm1(set_a_foreground, 237));
} else if max_colors >= 9 {
// bright black (the ninth color, looks grey)
add(&mut abandon_line_string, tparm1(set_a_foreground, 8));
} else if max_colors >= 2 {
if let Some(enter_bold_mode) = term.unwrap().enter_bold_mode.as_ref() {
// we might still get that color by setting black and going bold for bright
add(&mut abandon_line_string, tparm0(enter_bold_mode));
add(&mut abandon_line_string, tparm1(set_a_foreground, 0));
}
}
}
abandon_line_string.push_utfstr(&get_omitted_newline_str());
if let Some(exit_attribute_mode) =
term.and_then(|term| term.exit_attribute_mode.as_ref())
{
// normal text ANSI escape sequence
add(&mut abandon_line_string, tparm0(exit_attribute_mode));
}
let newline_glitch_width = if TERM_HAS_XN.load(Ordering::Relaxed) {
0
} else {
1
};
for _ in 0..screen_width - non_space_width - newline_glitch_width {
abandon_line_string.push(' ');
}
}
abandon_line_string.push('\r');
abandon_line_string.push_utfstr(get_omitted_newline_str());
// Now we are certainly on a new line. But we may have dropped the omitted newline char on
// it. So append enough spaces to overwrite the omitted newline char, and then clear all the
// spaces from the new line.
for _ in 0..non_space_width {
abandon_line_string.push(' ');
}
abandon_line_string.push('\r');
// Clear entire line. Zsh doesn't do this. Fish added this with commit 4417a6ee: If you have
// a prompt preceded by a new line, you'll get a line full of spaces instead of an empty
// line above your prompt. This doesn't make a difference in normal usage, but copying and
// pasting your terminal log becomes a pain. This commit clears that line, making it an
// actual empty line.
if !is_dumb() {
if let Some(clr_eol) = term.unwrap().clr_eol.as_ref() {
abandon_line_string.push_utfstr(&str2wcstring(clr_eol.as_bytes()));
}
}
let narrow_abandon_line_string = wcs2string(&abandon_line_string);
let _ = write_loop(&STDOUT_FILENO, &narrow_abandon_line_string);
self.actual.cursor.x = 0;
self.save_status();
}
/// Stat stdout and stderr and save result as the current timestamp.
/// This is used to avoid reacting to changes that we ourselves made to the screen.
pub fn save_status(&mut self) {
unsafe {
libc::fstat(STDOUT_FILENO, &mut self.prev_buff_1);
libc::fstat(STDERR_FILENO, &mut self.prev_buff_2);
}
}
/// \return whether we believe the cursor is wrapped onto the last line, and that line is
/// otherwise empty. This includes both soft and hard wrapping.
pub fn cursor_is_wrapped_to_own_line(&self) -> bool {
// Note == comparison against the line count is correct: we do not create a line just for the
// cursor. If there is a line containing the cursor, then it means that line has contents and we
// should return false.
// Don't consider dumb terminals to have wrapping for the purposes of this function.
self.actual.cursor.x == 0 && self.actual.cursor.y == self.actual.line_count() && !is_dumb()
}
/// Appends a character to the end of the line that the output cursor is on. This function
/// automatically handles linebreaks and lines longer than the screen width.
fn desired_append_char(
&mut self,
b: char,
c: HighlightSpec,
indent: usize,
prompt_width: usize,
bwidth: usize,
) {
let mut line_no = self.desired.cursor.y;
if b == '\n' {
// Current line is definitely hard wrapped.
// Create the next line.
self.desired.create_line(self.desired.cursor.y + 1);
self.desired.line_mut(self.desired.cursor.y).is_soft_wrapped = false;
self.desired.cursor.y += 1;
let line_no = self.desired.cursor.y;
self.desired.cursor.x = 0;
let indentation = prompt_width + indent * INDENT_STEP;
let line = self.desired.line_mut(line_no);
line.indentation = indentation;
for _ in 0..indentation {
self.desired_append_char(' ', HighlightSpec::default(), indent, prompt_width, 1);
}
} else if b == '\r' {
let current = self.desired.line_mut(line_no);
current.clear();
self.desired.cursor.x = 0;
} else {
let screen_width = self.desired.screen_width;
let cw = bwidth;
self.desired.create_line(line_no);
// Check if we are at the end of the line. If so, continue on the next line.
if screen_width.is_none_or(|sw| (self.desired.cursor.x + cw) > sw) {
// Current line is soft wrapped (assuming we support it).
self.desired.line_mut(self.desired.cursor.y).is_soft_wrapped = true;
line_no = self.desired.line_count();
self.desired.add_line();
self.desired.cursor.y += 1;
self.desired.cursor.x = 0;
}
self.desired.line_mut(line_no).append(b, c);
self.desired.cursor.x += cw;
// Maybe wrap the cursor to the next line, even if the line itself did not wrap. This
// avoids wonkiness in the last column.
if screen_width.is_none_or(|sw| self.desired.cursor.x >= sw) {
self.desired.line_mut(line_no).is_soft_wrapped = true;
self.desired.cursor.x = 0;
self.desired.cursor.y += 1;
}
}
}
/// Stat stdout and stderr and compare result to previous result in reader_save_status. Repaint
/// if modification time has changed.
fn check_status(&mut self) {
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
if !has_working_tty_timestamps() {
// We can't reliably determine if the terminal has been written to behind our back so we
// just assume that hasn't happened and hope for the best. This is important for multi-line
// prompts to work correctly.
return;
}
let mut post_buff_1: libc::stat = unsafe { std::mem::zeroed() };
let mut post_buff_2: libc::stat = unsafe { std::mem::zeroed() };
unsafe { libc::fstat(STDOUT_FILENO, &mut post_buff_1) };
unsafe { libc::fstat(STDERR_FILENO, &mut post_buff_2) };
// Yes these differ in one `_`. I hate it.
#[cfg(not(target_os = "netbsd"))]
let changed = self.prev_buff_1.st_mtime != post_buff_1.st_mtime
|| self.prev_buff_1.st_mtime_nsec != post_buff_1.st_mtime_nsec
|| self.prev_buff_2.st_mtime != post_buff_2.st_mtime
|| self.prev_buff_2.st_mtime_nsec != post_buff_2.st_mtime_nsec;
#[cfg(target_os = "netbsd")]
let changed = self.prev_buff_1.st_mtime != post_buff_1.st_mtime
|| self.prev_buff_1.st_mtimensec != post_buff_1.st_mtimensec
|| self.prev_buff_2.st_mtime != post_buff_2.st_mtime
|| self.prev_buff_2.st_mtimensec != post_buff_2.st_mtimensec;
if changed {
// Ok, someone has been messing with our screen. We will want to repaint. However, we do not
// know where the cursor is. It is our best bet that we are still on the same line, so we
// move to the beginning of the line, reset the modelled screen contents, and then set the
// modeled cursor y-pos to its earlier value.
let prev_line = self.actual.cursor.y;
self.reset_line(true /* repaint prompt */);
self.actual.cursor.y = prev_line;
}
}
/// Write the bytes needed to move screen cursor to the specified position to the specified
/// buffer. The actual_cursor field of the specified screen_t will be updated.
///
/// \param new_x the new x position
/// \param new_y the new y position
fn r#move(&mut self, new_x: usize, new_y: usize) {
if self.actual.cursor.x == new_x && self.actual.cursor.y == new_y {
return;
}
let mut zelf = self.scoped_buffer();
// If we are at the end of our window, then either the cursor stuck to the edge or it didn't. We
// don't know! We can fix it up though.
if zelf
.actual
.screen_width
.is_some_and(|sw| zelf.actual.cursor.x == sw)
{
// Either issue a cr to go back to the beginning of this line, or a nl to go to the
// beginning of the next one, depending on what we think is more efficient.
if new_y <= zelf.actual.cursor.y {
zelf.outp.borrow_mut().push(b'\r');
} else {
zelf.outp.borrow_mut().push(b'\n');
zelf.actual.cursor.y += 1;
}
// Either way we're not in the first column.
zelf.actual.cursor.x = 0;
}
let y_steps =
isize::try_from(new_y).unwrap() - isize::try_from(zelf.actual.cursor.y).unwrap();
let Some(term) = term() else {
return;
};
let term = term.as_ref();
let s = if y_steps < 0 {
term.cursor_up.as_ref()
} else if y_steps > 0 {
let s = term.cursor_down.as_ref();
if (shell_modes().c_oflag & ONLCR) != 0 && s.is_some_and(|s| s.as_bytes() == b"\n") {
// See GitHub issue #4505.
// Most consoles use a simple newline as the cursor down escape.
// If ONLCR is enabled (which it normally is) this will of course
// also move the cursor to the beginning of the line.
// We could do:
// if (std::strcmp(cursor_up, "\x1B[A") == 0) str = "\x1B[B";
// else ... but that doesn't work for unknown reasons.
zelf.actual.cursor.x = 0;
}
s
} else {
None
};
for _ in 0..y_steps.abs_diff(0) {
zelf.outp.borrow_mut().tputs_if_some(&s);
}
let mut x_steps =
isize::try_from(new_x).unwrap() - isize::try_from(zelf.actual.cursor.x).unwrap();
if x_steps != 0 && new_x == 0 {
zelf.outp.borrow_mut().push(b'\r');
x_steps = 0;
}
let (s, multi_str) = if x_steps < 0 {
(term.cursor_left.as_ref(), term.parm_left_cursor.as_ref())
} else {
(term.cursor_right.as_ref(), term.parm_right_cursor.as_ref())
};
// Use the bulk ('multi') zelf.output for cursor movement if it is supported and it would be shorter
// Note that this is required to avoid some visual glitches in iTerm (issue #1448).
let use_multi = multi_str.is_some_and(|ms| !ms.as_bytes().is_empty())
&& x_steps.abs_diff(0) * s.map_or(0, |s| s.as_bytes().len())
> multi_str.unwrap().as_bytes().len();
if use_multi {
let multi_param = tparm1(
multi_str.as_ref().unwrap(),
i32::try_from(x_steps.abs_diff(0)).unwrap(),
);
zelf.outp.borrow_mut().tputs_if_some(&multi_param);
} else {
for _ in 0..x_steps.abs_diff(0) {
zelf.outp.borrow_mut().tputs_if_some(&s);
}
}
zelf.actual.cursor.x = new_x;
zelf.actual.cursor.y = new_y;
}
/// Convert a wide character to a multibyte string and append it to the buffer.
fn write_char(&mut self, c: char, width: isize) {
let mut zelf = self.scoped_buffer();
zelf.actual.cursor.x = zelf.actual.cursor.x.wrapping_add(width as usize);
zelf.outp.borrow_mut().writech(c);
if Some(zelf.actual.cursor.x) == zelf.actual.screen_width && allow_soft_wrap() {
zelf.soft_wrap_location = Some(Cursor {
x: 0,
y: zelf.actual.cursor.y + 1,
});
// Note that our cursor position may be a lie: Apple Terminal makes the right cursor stick
// to the margin, while Ubuntu makes it "go off the end" (but still doesn't wrap). We rely
// on s_move to fix this up.
} else {
zelf.soft_wrap_location = None;
}
}
/// Send the specified string through tputs and append the output to the screen's outputter.
fn write_mbs(&mut self, s: &CStr) {
self.outp.borrow_mut().tputs(s);
}
fn write_mbs_if_some(&mut self, s: &Option<impl AsRef<CStr>>) -> bool {
self.outp.borrow_mut().tputs_if_some(s)
}
/// Convert a wide string to a multibyte string and append it to the buffer.
fn write_str(&mut self, s: &wstr) {
self.outp.borrow_mut().write_wstr(s);
}
/// Update the cursor as if soft wrapping had been performed.
/// We are about to output one or more characters onto the screen at the given x, y. If we are at the
/// end of previous line, and the previous line is marked as soft wrapping, then tweak the screen so
/// we believe we are already in the target position. This lets the terminal take care of wrapping,
/// which means that if you copy and paste the text, it won't have an embedded newline.
fn handle_soft_wrap(&mut self, x: usize, y: usize) {
if self
.soft_wrap_location
.as_ref()
.is_some_and(|swl| (x, y) == (swl.x, swl.y))
{
// We can soft wrap; but do we want to?
if self.desired.line(y - 1).is_soft_wrapped && allow_soft_wrap() {
// Yes. Just update the actual cursor; that will cause us to elide emitting the commands
// to move here, so we will just output on "one big line" (which the terminal soft
// wraps.
self.actual.cursor = self.soft_wrap_location.unwrap();
}
}
}
fn scoped_buffer(&mut self) -> impl ScopeGuarding<Target = &mut Screen> {
self.outp.borrow_mut().begin_buffering();
ScopeGuard::new(self, |zelf| {
zelf.outp.borrow_mut().end_buffering();
})
}
/// Update the screen to match the desired output.
fn update(&mut self, left_prompt: &wstr, right_prompt: &wstr, vars: &dyn Environment) {
// Helper function to set a resolved color, using the caching resolver.
let mut color_resolver = HighlightColorResolver::new();
let mut set_color = |zelf: &mut Self, c| {
let fg = color_resolver.resolve_spec(&c, false, vars);
let bg = color_resolver.resolve_spec(&c, true, vars);
zelf.outp.borrow_mut().set_color(fg, bg);
};
let mut cached_layouts = LAYOUT_CACHE_SHARED.lock().unwrap();
let mut zelf = self.scoped_buffer();
// Determine size of left and right prompt. Note these have already been truncated.
let left_prompt_layout = cached_layouts.calc_prompt_layout(left_prompt, None, usize::MAX);
let left_prompt_width = left_prompt_layout.last_line_width;
let right_prompt_width = cached_layouts
.calc_prompt_layout(right_prompt, None, usize::MAX)
.last_line_width;
// Figure out how many following lines we need to clear (probably 0).
let actual_lines_before_reset = zelf.actual_lines_before_reset;
zelf.actual_lines_before_reset = 0;
let mut need_clear_lines = zelf.need_clear_lines;
let mut need_clear_screen = zelf.need_clear_screen;
let mut has_cleared_screen = false;
let screen_width = zelf.desired.screen_width;
if zelf.actual.screen_width != screen_width {
// Ensure we don't issue a clear screen for the very first output, to avoid issue #402.
if zelf.actual.screen_width.is_some_and(|sw| sw > 0) {
need_clear_screen = true;
zelf.r#move(0, 0);
zelf.reset_line(false);
need_clear_lines |= zelf.need_clear_lines;
need_clear_screen |= zelf.need_clear_screen;
}
zelf.actual.screen_width = screen_width;
}
zelf.need_clear_lines = false;
zelf.need_clear_screen = false;
// Determine how many lines have stuff on them; we need to clear lines with stuff that we don't
// want.
let lines_with_stuff = std::cmp::max(actual_lines_before_reset, zelf.actual.line_count());
if zelf.desired.line_count() < lines_with_stuff {
need_clear_screen = true;
}
let term = term();
let term = term.as_ref();
// Output the left prompt if it has changed.
if left_prompt != zelf.actual_left_prompt {
zelf.r#move(0, 0);
let mut start = 0;
for line_break in left_prompt_layout.line_breaks {
zelf.write_str(&left_prompt[start..line_break]);
zelf.outp
.borrow_mut()
.tputs_if_some(&term.and_then(|term| term.clr_eol.as_ref()));
start = line_break;
}
zelf.write_str(&left_prompt[start..]);
zelf.actual_left_prompt = left_prompt.to_owned();
zelf.actual.cursor.x = left_prompt_width;
}
fn o_line(zelf: &Screen, i: usize) -> &Line {
zelf.desired.line(i)
}
fn s_line(zelf: &Screen, i: usize) -> &Line {
zelf.actual.line(i)
}
// Output all lines.
for i in 0..zelf.desired.line_count() {
zelf.actual.create_line(i);
let start_pos = if i == 0 { left_prompt_width } else { 0 };
let mut current_width = 0;
let mut has_cleared_line = false;
// If this is the last line, maybe we should clear the screen.
// Don't issue clr_eos if we think the cursor will end up in the last column - see #6951.
let should_clear_screen_this_line = need_clear_screen
&& i + 1 == zelf.desired.line_count()
&& term.is_some_and(|term| term.clr_eos.is_some())
&& !(zelf.desired.cursor.x == 0
&& zelf.desired.cursor.y == zelf.desired.line_count());
// skip_remaining is how many columns are unchanged on this line.
// Note that skip_remaining is a width, not a character count.
let mut skip_remaining = start_pos;
let shared_prefix = line_shared_prefix(o_line(&zelf, i), s_line(&zelf, i));
let mut skip_prefix = shared_prefix;
if shared_prefix < o_line(&zelf, i).indentation {
if o_line(&zelf, i).indentation > s_line(&zelf, i).indentation
&& !has_cleared_screen
&& term.is_some_and(|term| term.clr_eol.is_some() && term.clr_eos.is_some())
{
set_color(&mut zelf, HighlightSpec::new());
zelf.r#move(0, i);
let term = term.unwrap();
zelf.write_mbs_if_some(if should_clear_screen_this_line {
&term.clr_eos
} else {
&term.clr_eol
});
has_cleared_screen = should_clear_screen_this_line;
has_cleared_line = true;
}
skip_prefix = o_line(&zelf, i).indentation;
}
// Compute how much we should skip. At a minimum we skip over the prompt. But also skip
// over the shared prefix of what we want to output now, and what we output before, to
// avoid repeatedly outputting it.
if skip_prefix > 0 {
let skip_width = if shared_prefix < skip_prefix {
skip_prefix
} else {
o_line(&zelf, i).wcswidth_min_0(shared_prefix)
};
if skip_width > skip_remaining {
skip_remaining = skip_width;
}
}
if !should_clear_screen_this_line {
// If we're soft wrapped, and if we're going to change the first character of the next
// line, don't skip over the last two characters so that we maintain soft-wrapping.
if o_line(&zelf, i).is_soft_wrapped && i + 1 < zelf.desired.line_count() {
let mut next_line_will_change = true;
if i + 1 < zelf.actual.line_count() {
if line_shared_prefix(zelf.desired.line(i + 1), zelf.actual.line(i + 1)) > 0
{
next_line_will_change = false;
}
}
if next_line_will_change {
skip_remaining =
std::cmp::min(skip_remaining, zelf.actual.screen_width.unwrap() - 2);
}
}
}
// Skip over skip_remaining width worth of characters.
let mut j = 0;
while j < o_line(&zelf, i).len() {
let width = wcwidth_rendered(o_line(&zelf, i).char_at(j));
if skip_remaining < width {
break;
}
skip_remaining -= width;
current_width += width;
j += 1;
}
// Skip over zero-width characters (e.g. combining marks at the end of the prompt).
while j < o_line(&zelf, i).len() {
let width = wcwidth_rendered(o_line(&zelf, i).char_at(j));
if width > 0 {
break;
}
j += 1;
}
// Now actually output stuff.
loop {
let done = j >= o_line(&zelf, i).len();
// Clear the screen if we have not done so yet.
// If we are about to output into the last column, clear the screen first. If we clear
// the screen after we output into the last column, it can erase the last character due
// to the sticky right cursor. If we clear the screen too early, we can defeat soft
// wrapping.
if should_clear_screen_this_line
&& !has_cleared_screen
&& (done || Some(j + 1) == screen_width)
{
set_color(&mut zelf, HighlightSpec::new());
zelf.r#move(current_width, i);
zelf.write_mbs_if_some(&term.and_then(|term| term.clr_eos.as_ref()));
has_cleared_screen = true;
}
if done {
break;
}
zelf.handle_soft_wrap(current_width, i);
zelf.r#move(current_width, i);
let color = o_line(&zelf, i).color_at(j);
set_color(&mut zelf, color);
let ch = o_line(&zelf, i).char_at(j);
let width = wcwidth_rendered(ch);
zelf.write_char(ch, isize::try_from(width).unwrap());
current_width += width;
j += 1;
}
let mut clear_remainder = false;
// Clear the remainder of the line if we need to clear and if we didn't write to the end of
// the line. If we did write to the end of the line, the "sticky right edge" (as part of
// auto_right_margin) means that we'll be clearing the last character we wrote!
if has_cleared_screen || has_cleared_line {
// Already cleared everything.
clear_remainder = false;
} else if need_clear_lines && screen_width.is_some_and(|sw| current_width < sw) {
clear_remainder = true;
} else if right_prompt_width < zelf.last_right_prompt_width {
clear_remainder = true;
} else {
// This wcswidth shows up strong in the profile.
// Only do it if the previous line could conceivably be wider.
// That means if it is a prefix of the current one we can skip it.
if s_line(&zelf, i).text.len() != shared_prefix {
let prev_width = s_line(&zelf, i).wcswidth_min_0(usize::MAX);
clear_remainder = prev_width > current_width;
}
}
// We unset the color even if we don't clear the line.
// This means that we switch background correctly on the next,
// including our weird implicit bolding.
set_color(&mut zelf, HighlightSpec::new());
if let (true, Some(clr_eol)) =
(clear_remainder, term.and_then(|term| term.clr_eol.as_ref()))
{
zelf.r#move(current_width, i);
zelf.write_mbs(clr_eol);
}
// Output any rprompt if this is the first line.
if i == 0 && right_prompt_width > 0 {
// Move the cursor to the beginning of the line first to be independent of the width.
// This helps prevent staircase effects if fish and the terminal disagree.
zelf.r#move(0, 0);
zelf.r#move(screen_width.unwrap() - right_prompt_width, i);
set_color(&mut zelf, HighlightSpec::new());
zelf.write_str(right_prompt);
zelf.actual.cursor.x += right_prompt_width;
// We output in the last column. Some terms (Linux) push the cursor further right, past
// the window. Others make it "stick." Since we don't really know which is which, issue
// a cr so it goes back to the left.
//
// However, if the user is resizing the window smaller, then it's possible the cursor
// wrapped. If so, then a cr will go to the beginning of the following line! So instead
// issue a bunch of "move left" commands to get back onto the line, and then jump to the
// front of it.
let Cursor { x, y } = zelf.actual.cursor;
zelf.r#move(x - right_prompt_width, y);
zelf.write_str(L!("\r"));
zelf.actual.cursor.x = 0;
}
}
// Also move the cursor to the beginning of the line here,
// in case we're wrong about the width anywhere.
// Don't do it when running in midnight_commander because of
// https://midnight-commander.org/ticket/4258.
if !MIDNIGHT_COMMANDER_HACK.load() {
zelf.r#move(0, 0);
}
// Clear remaining lines (if any) if we haven't cleared the screen.
if let (false, true, Some(clr_eol)) = (
has_cleared_screen,
need_clear_screen,
term.and_then(|term| term.clr_eol.as_ref()),
) {
set_color(&mut zelf, HighlightSpec::new());
for i in zelf.desired.line_count()..lines_with_stuff {
zelf.r#move(0, i);
zelf.write_mbs(clr_eol);
}
}
let Cursor { x, y } = zelf.desired.cursor;
zelf.r#move(x, y);
set_color(&mut zelf, HighlightSpec::new());
// We have now synced our actual screen against our desired screen. Note that this is a big
// assignment!
zelf.actual = zelf.desired.clone();
zelf.last_right_prompt_width = right_prompt_width;
}
}
/// Issues an immediate clr_eos.
pub fn screen_force_clear_to_end() {
Outputter::stdoutput()
.borrow_mut()
.tputs_if_some(&term().unwrap().clr_eos);
}
/// Information about the layout of a prompt.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PromptLayout {
/// line breaks when rendering the prompt
pub line_breaks: Vec<usize>,
/// width of the longest line
pub max_line_width: usize,
/// width of the last line
pub last_line_width: usize,
}
// Fields exposed for testing.
pub struct PromptCacheEntry {
/// Original prompt string.
pub text: WString,
/// Max line width when computing layout (for truncation).
pub max_line_width: usize,
/// Resulting truncated prompt string.
pub trunc_text: WString,
/// Resulting layout.
pub layout: PromptLayout,
}
// Maintain a mapping of escape sequences to their widths for fast lookup.
#[derive(Default)]
pub struct LayoutCache {
// Cached escape sequences we've already detected in the prompt and similar strings, ordered
// lexicographically.
esc_cache: Vec<WString>,
// LRU-list of prompts and their layouts.
// Use a list so we can promote to the front on a cache hit.
// Exposed for testing.
pub prompt_cache: LinkedList<PromptCacheEntry>,
}
// Singleton of the cached escape sequences seen in prompts and similar strings.
// Note this is deliberately exported so that init_curses can clear it.
pub static LAYOUT_CACHE_SHARED: Mutex<LayoutCache> = Mutex::new(LayoutCache::new());
impl LayoutCache {
pub const fn new() -> Self {
Self {
esc_cache: vec![],
prompt_cache: LinkedList::new(),
}
}
pub const PROMPT_CACHE_MAX_SIZE: usize = 12;
/// \return the size of the escape code cache.
pub fn esc_cache_size(&self) -> usize {
self.esc_cache.len()
}
/// Insert the entry \p str in its sorted position, if it is not already present in the cache.
pub fn add_escape_code(&mut self, s: WString) {
if let Err(pos) = self.esc_cache.binary_search(&s) {
self.esc_cache.insert(pos, s);
}
}
/// \return the length of an escape code, accessing and perhaps populating the cache.
pub fn escape_code_length(&mut self, code: &wstr) -> usize {
if code.char_at(0) != '\x1B' {
return 0;
}
let mut esc_seq_len = self.find_escape_code(code);
if esc_seq_len != 0 {
return esc_seq_len;
}
if let Some(len) = escape_code_length(code) {
self.add_escape_code(code[..len].to_owned());
esc_seq_len = len;
}
esc_seq_len
}
/// \return the length of a string that matches a prefix of \p entry.
pub fn find_escape_code(&self, entry: &wstr) -> usize {
// Do a binary search and see if the escape code right before our entry is a prefix of our
// entry. Note this assumes that escape codes are prefix-free: no escape code is a prefix of
// another one. This seems like a safe assumption.
match self.esc_cache.binary_search_by(|e| e[..].cmp(entry)) {
Ok(_) => return entry.len(),
Err(pos) => {
if pos != 0 {
let candidate = &self.esc_cache[pos - 1];
if string_prefixes_string(candidate, entry) {
return candidate.len();
}
}
}
}
0
}
/// Computes a prompt layout for \p prompt_str, perhaps truncating it to \p max_line_width.
/// \return the layout, and optionally the truncated prompt itself, by reference.
pub fn calc_prompt_layout(
&mut self,
prompt_str: &wstr,
out_trunc_prompt: Option<&mut WString>,
max_line_width: usize, /* = usize::MAX */
) -> PromptLayout {
// FIXME: we could avoid allocating trunc_prompt if max_line_width is SIZE_T_MAX.
if self.find_prompt_layout(prompt_str, max_line_width) {
let entry = self.prompt_cache.front().unwrap();
out_trunc_prompt.map(|prompt| *prompt = entry.trunc_text.clone());
return entry.layout.clone();
}
let mut layout = PromptLayout::default();
let mut trunc_prompt = WString::new();
let mut run_start = 0;
while run_start < prompt_str.len() {
let mut run_end = 0;
let mut line_width = measure_run_from(prompt_str, run_start, Some(&mut run_end), self);
if line_width <= max_line_width {
// No truncation needed on this line.
trunc_prompt.extend(prompt_str[run_start..run_end].chars());
} else {
// Truncation needed on this line.
let mut run_storage = prompt_str[run_start..run_end].to_owned();
truncate_run(&mut run_storage, max_line_width, &mut line_width, self);
trunc_prompt.extend(run_storage.chars());
}
layout.max_line_width = std::cmp::max(layout.max_line_width, line_width);
layout.last_line_width = line_width;
let endc = prompt_str.char_at(run_end);
if endc != '\0' {
if endc == '\n' || endc == '\x0C' {
layout.line_breaks.push(trunc_prompt.len());
// If the prompt ends in a new line, that's one empy last line.
if run_end == prompt_str.len() - 1 {
layout.last_line_width = 0;
}
}
trunc_prompt.push(endc);
run_start = run_end + 1;
} else {
break;
}
}
out_trunc_prompt.map(|prompt| *prompt = trunc_prompt.clone());
self.add_prompt_layout(PromptCacheEntry {
text: prompt_str.to_owned(),
max_line_width,
trunc_text: trunc_prompt,
layout: layout.clone(),
});
layout
}
pub fn clear(&mut self) {
self.esc_cache.clear();
self.prompt_cache.clear();
}
/// Add a cache entry.
/// Exposed for testing.
pub fn add_prompt_layout(&mut self, entry: PromptCacheEntry) {
self.prompt_cache.push_front(entry);
if self.prompt_cache.len() > Self::PROMPT_CACHE_MAX_SIZE {
self.prompt_cache.pop_back();
}
}
/// Finds the layout for a prompt, promoting it to the front. Returns nullptr if not found.
/// Note this points into our cache; do not modify the cache while the pointer lives.
/// Exposed for testing.
pub fn find_prompt_layout(
&mut self,
input: &wstr,
max_line_width: usize, /* = usize::MAX */
) -> bool {
let mut i = 0;
for entry in &self.prompt_cache {
if entry.text == input && entry.max_line_width == max_line_width {
break;
}
i += 1;
}
if i < self.prompt_cache.len() {
// Found it. Move it to the front if not already there.
if i > 0 {
let mut tail = self.prompt_cache.split_off(i);
let extracted = tail.pop_front().unwrap();
self.prompt_cache.append(&mut tail);
self.prompt_cache.push_front(extracted);
}
return true;
}
false
}
}
/// Returns the number of characters in the escape code starting at 'code'. We only handle sequences
/// that begin with \x1B. If it doesn't we return zero. We also return zero if we don't recognize
/// the escape sequence based on querying terminfo and other heuristics.
pub fn escape_code_length(code: &wstr) -> Option<usize> {
if code.char_at(0) != '\x1B' {
return None;
}
is_visual_escape_seq(code)
.or_else(|| is_screen_name_escape_seq(code))
.or_else(|| is_osc_escape_seq(code))
.or_else(|| is_three_byte_escape_seq(code))
.or_else(|| is_csi_style_escape_seq(code))
.or_else(|| is_two_byte_escape_seq(code))
}
pub fn screen_clear() -> WString {
term()
.unwrap()
.clear_screen
.as_ref()
.map(|clear_screen| str2wcstring(clear_screen.as_bytes()))
.unwrap_or_default()
}
static MIDNIGHT_COMMANDER_HACK: RelaxedAtomicBool = RelaxedAtomicBool::new(false);
pub fn screen_set_midnight_commander_hack() {
MIDNIGHT_COMMANDER_HACK.store(true)
}
/// The number of characters to indent new blocks.
const INDENT_STEP: usize = 4;
/// Tests if the specified narrow character sequence is present at the specified position of the
/// specified wide character string. All of \c seq must match, but str may be longer than seq.
fn try_sequence(seq: &[u8], s: &wstr) -> usize {
let mut i = 0;
loop {
if i == seq.len() {
return i;
}
if char::from(seq[i]) != s.char_at(i) {
return 0;
}
i += 1;
}
}
/// Returns the number of columns left until the next tab stop, given the current cursor position.
fn next_tab_stop(current_line_width: usize) -> usize {
// Assume tab stops every 8 characters if undefined.
let tab_width = term().unwrap().init_tabs.unwrap_or(8);
((current_line_width / tab_width) + 1) * tab_width
}
/// Whether we permit soft wrapping. If so, in some cases we don't explicitly move to the second
/// physical line on a wrapped logical line; instead we just output it.
fn allow_soft_wrap() -> bool {
// Should we be looking at eat_newline_glitch as well?
term().unwrap().auto_right_margin
}
/// Does this look like the escape sequence for setting a screen name?
fn is_screen_name_escape_seq(code: &wstr) -> Option<usize> {
// Tmux escapes start with `\ePtmux;` and end also in `\e\\`,
// so we can just handle them here.
let tmux_seq = L!("Ptmux;");
let mut is_tmux = false;
if code.char_at(1) != 'k' {
if code.starts_with(tmux_seq) {
is_tmux = true;
} else {
return None;
}
}
let screen_name_end_sentinel = L!("\x1B\\");
let mut offset = 2;
let escape_sequence_end;
loop {
let Some(pos) = code[offset..].find(screen_name_end_sentinel) else {
// Consider just <esc>k to be the code.
// (note: for the tmux sequence this is broken, but since we have no idea...)
escape_sequence_end = 2;
break;
};
let screen_name_end = offset + pos;
// The tmux sequence requires that all escapes in the payload sequence
// be doubled. So if we have \e\e\\ that's still not the end.
if is_tmux {
let mut esc_count = 0;
let mut i = screen_name_end;
while i > 0 && code.as_char_slice()[i - 1] == '\x1B' {
i -= 1;
if i > 0 {
esc_count += 1;
}
}
if esc_count % 2 == 1 {
offset = screen_name_end + 1;
continue;
}
}
escape_sequence_end = screen_name_end + screen_name_end_sentinel.len();
break;
}
Some(escape_sequence_end)
}
/// Operating System Command (OSC) escape codes, used by iTerm2 and others:
/// ESC followed by ], terminated by either BEL or escape + backslash.
/// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
/// and https://iterm2.com/documentation-escape-codes.html .
fn is_osc_escape_seq(code: &wstr) -> Option<usize> {
if code.char_at(1) == ']' {
// Start at 2 to skip over <esc>].
let mut cursor = 2;
while cursor < code.len() {
let code = code.as_char_slice();
// Consume a sequence of characters up to <esc>\ or <bel>.
if code[cursor] == '\x07' || (code[cursor] == '\\' && code[cursor - 1] == '\x1B') {
return Some(cursor + 1);
}
cursor += 1;
}
}
None
}
/// Generic VT100 three byte sequence: CSI followed by something in the range @ through _.
fn is_three_byte_escape_seq(code: &wstr) -> Option<usize> {
if code.char_at(1) == '[' && (code.char_at(2) >= '@' && code.char_at(2) <= '_') {
return Some(3);
}
None
}
/// Generic VT100 two byte sequence: <esc> followed by something in the range @ through _.
fn is_two_byte_escape_seq(code: &wstr) -> Option<usize> {
if code.char_at(1) >= '@' && code.char_at(1) <= '_' {
return Some(2);
}
None
}
/// Generic VT100 CSI-style sequence. <esc>, followed by zero or more ASCII characters NOT in
/// the range [@,_], followed by one character in that range.
/// This will also catch color sequences.
fn is_csi_style_escape_seq(code: &wstr) -> Option<usize> {
if code.char_at(1) != '[' {
return None;
}
// Start at 2 to skip over <esc>[
let mut cursor = 2;
while cursor < code.len() {
// Consume a sequence of ASCII characters not in the range [@, ~].
let widechar = code.as_char_slice()[cursor];
// If we're not in ASCII, just stop.
if !widechar.is_ascii() {
break;
}
// If we're the end character, then consume it and then stop.
if ('@'..'~').contains(&widechar) {
cursor += 1;
break;
}
cursor += 1;
}
// cursor now indexes just beyond the end of the sequence (or at the terminating zero).
Some(cursor)
}
/// Detect whether the escape sequence sets one of the terminal attributes that affects how text is
/// displayed other than the color.
fn is_visual_escape_seq(code: &wstr) -> Option<usize> {
let term = term()?;
let esc2 = [
&term.enter_bold_mode,
&term.exit_attribute_mode,
&term.enter_underline_mode,
&term.exit_underline_mode,
&term.enter_standout_mode,
&term.exit_standout_mode,
&term.enter_blink_mode,
&term.enter_protected_mode,
&term.enter_italics_mode,
&term.exit_italics_mode,
&term.enter_reverse_mode,
&term.enter_shadow_mode,
&term.exit_shadow_mode,
&term.enter_secure_mode,
&term.enter_dim_mode,
&term.enter_alt_charset_mode,
&term.exit_alt_charset_mode,
];
for p in &esc2 {
let Some(p) = p else { continue };
// Test both padded and unpadded version, just to be safe. Most versions of fish_tparm don't
// actually seem to do anything these days.
let esc_seq_len = std::cmp::max(
try_sequence(tparm0(p).unwrap().as_bytes(), code),
try_sequence(p.as_bytes(), code),
);
if esc_seq_len != 0 {
return Some(esc_seq_len);
}
}
None
}
/// \return whether \p c ends a measuring run.
fn is_run_terminator(c: char) -> bool {
matches!(c, '\0' | '\n' | '\r' | '\x0C')
}
/// Measure a run of characters in \p input starting at \p start.
/// Stop when we reach a run terminator, and return its index in \p out_end (if not null).
/// Note \0 is a run terminator so there will always be one.
/// We permit escape sequences to have run terminators other than \0. That is, escape sequences may
/// have embedded newlines, etc.; it's unclear if this is possible but we allow it.
fn measure_run_from(
input: &wstr,
start: usize,
out_end: Option<&mut usize>,
cache: &mut LayoutCache,
) -> usize {
let mut width = 0;
let mut idx = start;
while !is_run_terminator(input.char_at(idx)) {
if input.char_at(idx) == '\x1B' {
// This is the start of an escape code; we assume it has width 0.
// -1 because we are going to increment in the loop.
let len = cache.escape_code_length(&input[idx..]);
if len > 0 {
idx += len - 1;
}
} else if input.char_at(idx) == '\t' {
width = next_tab_stop(width);
} else {
// Ordinary char. Add its width with care to ignore control chars which have width -1.
width += wcwidth_rendered(input.char_at(idx));
}
idx += 1;
}
out_end.map(|end| *end = idx);
width
}
/// Attempt to truncate the prompt run \p run, which has width \p width, to \p no more than
/// desired_width. \return the resulting width and run by reference.
fn truncate_run(
run: &mut WString,
desired_width: usize,
width: &mut usize,
cache: &mut LayoutCache,
) {
let mut curr_width = *width;
if curr_width < desired_width {
return;
}
// Bravely prepend ellipsis char and skip it.
// Ellipsis is always width 1.
let ellipsis = get_ellipsis_char();
run.insert(0, ellipsis);
curr_width += 1;
// Start removing characters after ellipsis.
// Note we modify 'run' inside this loop.
let mut idx = 1;
while curr_width > desired_width && idx < run.len() {
let c = run.as_char_slice()[idx];
assert!(
!is_run_terminator(c),
"Should not have run terminator inside run"
);
if c == '\x1B' {
let len = cache.escape_code_length(&run[idx..]);
idx += std::cmp::max(len, 1);
} else if c == '\t' {
// Tabs would seem to be quite annoying to measure while truncating.
// We simply remove these and start over.
run.remove(idx);
curr_width = measure_run_from(run, 0, None, cache);
idx = 0;
} else {
let char_width = wcwidth_rendered(c);
curr_width -= std::cmp::min(curr_width, char_width);
run.remove(idx);
}
}
*width = curr_width;
}
fn calc_prompt_lines(prompt: &wstr) -> usize {
// Hack for the common case where there's no newline at all. I don't know if a newline can
// appear in an escape sequence, so if we detect a newline we have to defer to
// calc_prompt_width_and_lines.
let mut result = 1;
if prompt.chars().any(|c| matches!(c, '\n' | '\x0C')) {
result = LAYOUT_CACHE_SHARED
.lock()
.unwrap()
.calc_prompt_layout(prompt, None, usize::MAX)
.line_breaks
.len()
+ 1;
}
result
}
/// Returns the length of the "shared prefix" of the two lines, which is the run of matching text
/// and colors. If the prefix ends on a combining character, do not include the previous character
/// in the prefix.
fn line_shared_prefix(a: &Line, b: &Line) -> usize {
let mut idx = 0;
let max = std::cmp::min(a.len(), b.len());
while idx < max {
let ac = a.char_at(idx);
let bc = b.char_at(idx);
// We're done if the text or colors are different.
if ac != bc || a.color_at(idx) != b.color_at(idx) {
if idx > 0 {
let mut c = None;
// Possible combining mark, go back until we hit _two_ printable characters or idx
// of 0.
if fish_wcwidth(a.char_at(idx)) < 1 {
c = Some(&a);
} else if fish_wcwidth(b.char_at(idx)) < 1 {
c = Some(&b);
}
if let Some(c) = c {
while idx > 1
&& (fish_wcwidth(c.char_at(idx - 1)) < 1
|| fish_wcwidth(c.char_at(idx)) < 1)
{
idx -= 1;
}
if idx == 1 && fish_wcwidth(c.char_at(idx)) < 1 {
idx = 0;
}
}
}
break;
}
idx += 1;
}
idx
}
/// Returns true if we are using a dumb terminal.
fn is_dumb() -> bool {
term().is_none_or(|term| {
term.cursor_up.is_none()
|| term.cursor_down.is_none()
|| term.cursor_left.is_none()
|| term.cursor_right.is_none()
})
}
#[derive(Default)]
struct ScreenLayout {
// The left prompt that we're going to use.
left_prompt: WString,
// How much space to leave for it.
left_prompt_space: usize,
// The right prompt.
right_prompt: WString,
// The autosuggestion.
autosuggestion: WString,
}
// Given a vector whose indexes are offsets and whose values are the widths of the string if
// truncated at that offset, return the offset that fits in the given width. Returns
// width_by_offset.size() - 1 if they all fit. The first value in width_by_offset is assumed to be
// 0.
fn truncation_offset_for_width(width_by_offset: &[usize], max_width: usize) -> usize {
assert!(width_by_offset[0] == 0);
let mut i = 1;
while i < width_by_offset.len() {
if width_by_offset[i] > max_width {
break;
}
i += 1;
}
// i is the first index that did not fit; i-1 is therefore the last that did.
i - 1
}
fn compute_layout(
screen_width: usize,
left_untrunc_prompt: &wstr,
right_untrunc_prompt: &wstr,
commandline: &wstr,
autosuggestion_str: &wstr,
) -> ScreenLayout {
let mut result = ScreenLayout::default();
// Truncate both prompts to screen width (#904).
let mut left_prompt = WString::new();
let left_prompt_layout = LAYOUT_CACHE_SHARED.lock().unwrap().calc_prompt_layout(
left_untrunc_prompt,
Some(&mut left_prompt),
screen_width,
);
let mut right_prompt = WString::new();
let right_prompt_layout = LAYOUT_CACHE_SHARED.lock().unwrap().calc_prompt_layout(
right_untrunc_prompt,
Some(&mut right_prompt),
screen_width,
);
let left_prompt_width = left_prompt_layout.last_line_width;
let mut right_prompt_width = right_prompt_layout.last_line_width;
if left_prompt_width + right_prompt_width > screen_width {
// Nix right_prompt.
right_prompt.truncate(0);
right_prompt_width = 0;
}
// Now we should definitely fit.
assert!(left_prompt_width + right_prompt_width <= screen_width);
// Get the width of the first line, and if there is more than one line.
let mut multiline = false;
let mut first_line_width = 0;
for c in commandline.chars() {
if c == '\n' {
multiline = true;
break;
} else {
first_line_width += wcwidth_rendered(c);
}
}
let first_command_line_width = first_line_width;
// If we have more than one line, ensure we have no autosuggestion.
let mut autosuggestion = autosuggestion_str;
let mut autosuggest_total_width = 0;
let mut autosuggest_truncated_widths = vec![];
if multiline {
autosuggestion = L!("");
} else {
autosuggest_truncated_widths.reserve(1 + autosuggestion_str.len());
for c in autosuggestion.chars() {
autosuggest_truncated_widths.push(autosuggest_total_width);
autosuggest_total_width += wcwidth_rendered(c);
}
}
// Here are the layouts we try in turn:
//
// 1. Left prompt visible, right prompt visible, command line visible, autosuggestion visible.
//
// 2. Left prompt visible, right prompt visible, command line visible, autosuggestion truncated
// (possibly to zero).
//
// 3. Left prompt visible, right prompt hidden, command line visible, autosuggestion visible
//
// 4. Left prompt visible, right prompt hidden, command line visible, autosuggestion truncated
//
// 5. Newline separator (left prompt visible, right prompt hidden, command line visible,
// autosuggestion visible).
//
// A remark about layout #4: if we've pushed the command line to a new line, why can't we draw
// the right prompt? The issue is resizing: if you resize the window smaller, then the right
// prompt will wrap to the next line. This means that we can't go back to the line that we were
// on, and things turn to chaos very quickly.
// Case 1
let calculated_width =
left_prompt_width + right_prompt_width + first_command_line_width + autosuggest_total_width;
if calculated_width <= screen_width {
result.left_prompt = left_prompt;
result.left_prompt_space = left_prompt_width;
result.right_prompt = right_prompt;
result.autosuggestion = autosuggestion.to_owned();
return result;
}
// Case 2. Note that we require strict inequality so that there's always at least one space
// between the left edge and the rprompt.
let calculated_width = left_prompt_width + right_prompt_width + first_command_line_width;
if calculated_width <= screen_width {
result.left_prompt = left_prompt;
result.left_prompt_space = left_prompt_width;
result.right_prompt = right_prompt;
// Need at least two characters to show an autosuggestion.
let available_autosuggest_space =
screen_width - (left_prompt_width + right_prompt_width + first_command_line_width);
if autosuggest_total_width > 0 && available_autosuggest_space > 2 {
let truncation_offset = truncation_offset_for_width(
&autosuggest_truncated_widths,
available_autosuggest_space - 2,
);
result.autosuggestion = autosuggestion[..truncation_offset].to_owned();
result.autosuggestion.push(get_ellipsis_char());
}
return result;
}
// Case 3
let calculated_width = left_prompt_width + first_command_line_width + autosuggest_total_width;
if calculated_width <= screen_width {
result.left_prompt = left_prompt;
result.left_prompt_space = left_prompt_width;
result.autosuggestion = autosuggestion.to_owned();
return result;
}
// Case 4
let calculated_width = left_prompt_width + first_command_line_width;
if calculated_width <= screen_width {
result.left_prompt = left_prompt;
result.left_prompt_space = left_prompt_width;
// Need at least two characters to show an autosuggestion.
let available_autosuggest_space =
screen_width - (left_prompt_width + first_command_line_width);
if autosuggest_total_width > 0 && available_autosuggest_space > 2 {
let truncation_offset = truncation_offset_for_width(
&autosuggest_truncated_widths,
available_autosuggest_space - 2,
);
result.autosuggestion = autosuggestion[..truncation_offset].to_owned();
result.autosuggestion.push(get_ellipsis_char());
}
return result;
}
// Case 5
result.left_prompt = left_prompt;
result.left_prompt_space = left_prompt_width;
result.autosuggestion = autosuggestion.to_owned();
result
}
// Display non-printable control characters as a graphic symbol.
// This is to prevent control characters like \t and \v from moving the
// cursor in a way we don't handle. The ones we do handle are \r and
// \n.
// See https://unicode-table.com/en/blocks/control-pictures/
fn rendered_character(c: char) -> char {
if fish_reserved_codepoint(c) {
return '<27>'; // replacement character
}
if c <= '\x1F' {
char::from_u32(u32::from(c) + 0x2400).unwrap()
} else {
c
}
}
fn wcwidth_rendered(c: char) -> usize {
usize::try_from(fish_wcwidth(rendered_character(c))).unwrap_or_default()
}