use nu_ansi_term::Style;
use crate::terminal_extensions::semantic_prompt::{PromptKind, SemanticPromptMarkers};
use crate::Prompt;
use super::utils::strip_ansi;
#[derive(Clone)]
pub struct StyledText {
pub buffer: Vec<(Style, String)>,
}
impl Default for StyledText {
fn default() -> Self {
Self::new()
}
}
impl StyledText {
pub const fn new() -> Self {
Self { buffer: vec![] }
}
pub fn push(&mut self, styled_string: (Style, String)) {
self.buffer.push(styled_string);
}
pub fn style_range(&mut self, from: usize, to: usize, new_style: Style) {
let (from, to) = if from > to { (to, from) } else { (from, to) };
let mut current_idx = 0;
let mut pair_idx = 0;
while pair_idx < self.buffer.len() {
let pair = &mut self.buffer[pair_idx];
let end_idx = current_idx + pair.1.len();
enum Position {
Before,
In,
After,
}
let start_position = if current_idx < from {
Position::Before
} else if current_idx >= to {
Position::After
} else {
Position::In
};
let end_position = if end_idx < from {
Position::Before
} else if end_idx > to {
Position::After
} else {
Position::In
};
match (start_position, end_position) {
(Position::Before, Position::After) => {
let mut in_range = pair.1.split_off(from - current_idx);
let after_range = in_range.split_off(to - from);
let in_range = (new_style, in_range);
let after_range = (pair.0, after_range);
self.buffer.insert(pair_idx + 1, in_range);
self.buffer.insert(pair_idx + 2, after_range);
break;
}
(Position::Before, Position::In) => {
let in_range = pair.1.split_off(from - current_idx);
pair_idx += 1; self.buffer.insert(pair_idx, (new_style, in_range));
}
(Position::In, Position::After) => {
let after_range = pair.1.split_off(to - current_idx);
let old_style = pair.0;
pair.0 = new_style;
if !after_range.is_empty() {
self.buffer.insert(pair_idx + 1, (old_style, after_range));
}
break;
}
(Position::In, Position::In) => pair.0 = new_style,
(Position::After, _) => break,
_ => (),
}
current_idx = end_idx;
pair_idx += 1;
}
}
pub fn render_around_insertion_point(
&self,
insertion_point: usize,
prompt: &dyn Prompt,
use_ansi_coloring: bool,
semantic_markers: Option<&dyn SemanticPromptMarkers>,
) -> (String, String) {
let mut current_idx = 0;
let mut left_string = String::new();
let mut right_string = String::new();
let multiline_prompt = prompt.render_prompt_multiline_indicator();
let prompt_style = Style::new().fg(prompt.get_prompt_multiline_color());
for pair in &self.buffer {
if current_idx >= insertion_point {
right_string.push_str(&render_as_string(
pair,
&prompt_style,
&multiline_prompt,
semantic_markers,
));
} else if pair.1.len() + current_idx <= insertion_point {
left_string.push_str(&render_as_string(
pair,
&prompt_style,
&multiline_prompt,
semantic_markers,
));
} else if pair.1.len() + current_idx > insertion_point {
let offset = insertion_point - current_idx;
let left_side = pair.1[..offset].to_string();
let right_side = pair.1[offset..].to_string();
left_string.push_str(&render_as_string(
&(pair.0, left_side),
&prompt_style,
&multiline_prompt,
semantic_markers,
));
right_string.push_str(&render_as_string(
&(pair.0, right_side),
&prompt_style,
&multiline_prompt,
semantic_markers,
));
}
current_idx += pair.1.len();
}
if use_ansi_coloring {
(left_string, right_string)
} else {
(strip_ansi(&left_string), strip_ansi(&right_string))
}
}
pub fn render_simple(&self) -> String {
self.buffer
.iter()
.map(|(style, text)| style.paint(text).to_string())
.collect()
}
pub fn raw_string(&self) -> String {
self.buffer.iter().map(|(_, str)| str.as_str()).collect()
}
}
fn shows_on_a_blank_cell(style: &Style) -> bool {
style.background.is_some() || style.is_reverse || style.is_underline || style.is_strikethrough
}
fn render_as_string(
renderable: &(Style, String),
prompt_style: &Style,
multiline_prompt: &str,
semantic_markers: Option<&dyn SemanticPromptMarkers>,
) -> String {
let mut rendered = String::new();
let formatted_multiline_prompt = if let Some(markers) = semantic_markers {
format!(
"\n{}{}{}",
markers.prompt_start(PromptKind::Secondary),
multiline_prompt,
markers.command_input_start()
)
} else {
format!("\n{multiline_prompt}")
};
let mut lines = renderable.1.split('\n').peekable();
while let Some(line) = lines.next() {
let terminated = lines.peek().is_some();
let line = if terminated {
line.strip_suffix('\r').unwrap_or(line)
} else {
line
};
if terminated && shows_on_a_blank_cell(&renderable.0) {
rendered.push_str(&renderable.0.paint(format!("{line} ")).to_string());
} else {
rendered.push_str(&renderable.0.paint(line).to_string());
}
if terminated {
rendered.push_str(&prompt_style.paint(&formatted_multiline_prompt).to_string());
}
}
rendered
}
#[cfg(test)]
mod test {
use nu_ansi_term::{Color, Style};
use super::strip_ansi;
use crate::StyledText;
use rstest::rstest;
fn get_styled_text_template() -> (super::StyledText, Style, Style) {
let before_style = Style::new().on(Color::Black);
let after_style = Style::new().on(Color::Red);
(
super::StyledText {
buffer: vec![
(before_style, "aaa".into()),
(before_style, "bbb".into()),
(before_style, "ccc".into()),
],
},
before_style,
after_style,
)
}
#[test]
fn style_range_partial_update_one_part() {
let (styled_text_template, before_style, after_style) = get_styled_text_template();
let mut styled_text = styled_text_template.clone();
styled_text.style_range(0, 1, after_style);
assert_eq!(styled_text.buffer[0], (after_style, "a".into()));
assert_eq!(styled_text.buffer[1], (before_style, "aa".into()));
assert_eq!(styled_text.buffer[2], (before_style, "bbb".into()));
assert_eq!(styled_text.buffer[3], (before_style, "ccc".into()));
}
#[test]
fn style_range_complete_update_one_part() {
let (styled_text_template, before_style, after_style) = get_styled_text_template();
let mut styled_text = styled_text_template.clone();
styled_text.style_range(0, 3, after_style);
assert_eq!(styled_text.buffer[0], (after_style, "aaa".into()));
assert_eq!(styled_text.buffer[1], (before_style, "bbb".into()));
assert_eq!(styled_text.buffer[2], (before_style, "ccc".into()));
assert_eq!(styled_text.buffer.len(), 3);
}
#[test]
fn style_range_update_over_boundary() {
let (styled_text_template, before_style, after_style) = get_styled_text_template();
let mut styled_text = styled_text_template;
styled_text.style_range(0, 5, after_style);
assert_eq!(styled_text.buffer[0], (after_style, "aaa".into()));
assert_eq!(styled_text.buffer[1], (after_style, "bb".into()));
assert_eq!(styled_text.buffer[2], (before_style, "b".into()));
assert_eq!(styled_text.buffer[3], (before_style, "ccc".into()));
}
#[test]
fn style_range_update_over_part() {
let (styled_text_template, before_style, after_style) = get_styled_text_template();
let mut styled_text = styled_text_template;
styled_text.style_range(1, 7, after_style);
assert_eq!(styled_text.buffer[0], (before_style, "a".into()));
assert_eq!(styled_text.buffer[1], (after_style, "aa".into()));
assert_eq!(styled_text.buffer[2], (after_style, "bbb".into()));
assert_eq!(styled_text.buffer[3], (after_style, "c".into()));
assert_eq!(styled_text.buffer[4], (before_style, "cc".into()));
}
#[test]
fn style_range_last_letter() {
let (_, before_style, after_style) = get_styled_text_template();
let mut styled_text = StyledText {
buffer: vec![(before_style, "asdf".into())],
};
styled_text.style_range(3, 4, after_style);
assert_eq!(styled_text.buffer[0], (before_style, "asd".into()));
assert_eq!(styled_text.buffer[1], (after_style, "f".into()));
}
#[test]
fn style_range_from_second_to_last() {
let (_, before_style, after_style) = get_styled_text_template();
let mut styled_text = StyledText {
buffer: vec![(before_style, "asdf".into())],
};
styled_text.style_range(2, 3, after_style);
assert_eq!(styled_text.buffer[0], (before_style, "as".into()));
assert_eq!(styled_text.buffer[1], (after_style, "d".into()));
assert_eq!(styled_text.buffer[2], (before_style, "f".into()));
}
#[test]
fn regression_style_range_cargo_run() {
let (_, before_style, after_style) = get_styled_text_template();
let mut styled_text = StyledText {
buffer: vec![
(before_style, "cargo".into()),
(before_style, " ".into()),
(before_style, "run".into()),
],
};
styled_text.style_range(8, 7, after_style);
assert_eq!(styled_text.buffer[0], (before_style, "cargo".into()));
assert_eq!(styled_text.buffer[1], (before_style, " ".into()));
assert_eq!(styled_text.buffer[2], (before_style, "r".into()));
assert_eq!(styled_text.buffer[3], (after_style, "u".into()));
assert_eq!(styled_text.buffer[4], (before_style, "n".into()));
}
#[test]
fn test_render_multiline_without_semantic_markers() {
let style = Style::new();
let renderable = (style, "line1\nline2".to_string());
let prompt_style = Style::new();
let multiline_prompt = "::: ";
let result = super::render_as_string(&renderable, &prompt_style, multiline_prompt, None);
assert!(result.contains("\n::: "));
assert!(!result.contains("\x1b]133;A;k=s"));
}
#[test]
fn test_render_multiline_with_semantic_markers() {
use crate::terminal_extensions::semantic_prompt::Osc133Markers;
let style = Style::new();
let renderable = (style, "line1\nline2".to_string());
let prompt_style = Style::new();
let multiline_prompt = "::: ";
let markers = Osc133Markers;
let result =
super::render_as_string(&renderable, &prompt_style, multiline_prompt, Some(&markers));
assert!(result.contains("\x1b]133;A;k=s\x1b\\"));
assert!(result.contains("\x1b]133;B\x1b\\"));
}
#[test]
fn test_render_single_line_no_markers_emitted() {
use crate::terminal_extensions::semantic_prompt::Osc133Markers;
let style = Style::new();
let renderable = (style, "single line".to_string());
let prompt_style = Style::new();
let multiline_prompt = "::: ";
let markers = Osc133Markers;
let result =
super::render_as_string(&renderable, &prompt_style, multiline_prompt, Some(&markers));
assert!(!result.contains("\x1b]133;A;k=s"));
assert!(!result.contains("\x1b]133;B"));
}
#[test]
fn lone_terminator_chunk_gets_a_painted_cell() {
let style = Style::new().on(Color::LightGray);
for text in ["\n", "\r\n"] {
let result =
super::render_as_string(&(style, text.to_string()), &Style::new(), "::: ", None);
assert!(
strip_ansi(&result).starts_with(' '),
"{text:?} rendered {result:?}"
);
assert_eq!(
result,
format!("{}\n::: {}", style.paint(" "), style.paint(""))
);
}
}
#[test]
fn the_cell_survives_the_split_at_the_caret() {
let sel = Style::new().on(Color::LightGray);
let mut styled = StyledText {
buffer: vec![(Style::new(), "ab\ncd".into())],
};
styled.style_range(2, 3, sel);
let (left, right) =
styled.render_around_insertion_point(2, &crate::DefaultPrompt::default(), false, None);
assert_eq!(left, "ab");
assert!(right.starts_with(' '), "right was {right:?}");
}
#[rstest]
#[case::reverse(Style::new().reverse(), true)]
#[case::background(Style::new().on(Color::LightGray), true)]
#[case::underline(Style::new().underline(), true)]
#[case::strikethrough(Style::new().strikethrough(), true)]
#[case::foreground(Style::new().fg(Color::Green), false)]
#[case::bold(Style::new().bold(), false)]
#[case::plain(Style::new(), false)]
fn only_a_style_that_shows_on_a_blank_cell_gets_one(
#[case] style: Style,
#[case] expected: bool,
) {
let mut styled = StyledText {
buffer: vec![(Style::new(), "ab\ncd".into())],
};
styled.style_range(1, 4, style);
let rendered: String = styled
.buffer
.iter()
.map(|p| super::render_as_string(p, &Style::new(), "> ", None))
.collect();
let expected = if expected { "ab \n> cd" } else { "ab\n> cd" };
assert_eq!(strip_ansi(&rendered), expected);
}
#[test]
fn a_terminator_inside_a_wider_chunk_gets_a_cell_too() {
let style = Style::new().on(Color::LightGray);
let result =
super::render_as_string(&(style, "a\nb".to_string()), &Style::new(), "::: ", None);
assert_eq!(strip_ansi(&result), "a \n::: b");
}
#[test]
fn a_trailing_terminator_in_a_wider_chunk_gets_a_cell() {
let style = Style::new().on(Color::LightGray);
for text in ["bar\n", "o bar\n"] {
let result =
super::render_as_string(&(style, text.to_string()), &Style::new(), "::: ", None);
let first = strip_ansi(&result);
let first = first.split('\n').next().unwrap().to_string();
assert!(
first.ends_with(' '),
"{text:?} rendered first line {first:?}"
);
}
}
#[test]
fn a_crlf_terminator_paints_a_cell_and_drops_the_carriage_return() {
let style = Style::new().on(Color::LightGray);
let result =
super::render_as_string(&(style, "bar\r\n".to_string()), &Style::new(), "::: ", None);
assert!(!result.contains('\r'), "leaked a CR: {result:?}");
assert_eq!(strip_ansi(&result).split('\n').next().unwrap(), "bar ");
}
#[test]
fn a_trailing_carriage_return_is_not_a_terminator() {
let style = Style::new().on(Color::LightGray);
let result =
super::render_as_string(&(style, "bar\r".to_string()), &Style::new(), "::: ", None);
assert!(result.contains('\r'), "ate buffer content: {result:?}");
assert_eq!(strip_ansi(&result), "bar");
}
#[test]
fn an_unstyled_wider_terminator_chunk_gains_nothing() {
let result = super::render_as_string(
&(Style::new(), "a\nb\nc".to_string()),
&Style::new(),
"::: ",
None,
);
assert_eq!(strip_ansi(&result), "a\n::: b\n::: c");
}
#[test]
fn an_unstyled_terminator_chunk_gains_nothing() {
use crate::{Highlighter, SimpleMatchHighlighter};
let styled = SimpleMatchHighlighter::default().highlight("\n", 0);
assert_eq!(styled.buffer[0].0.background, None, "premise of this test");
let (left, right) =
styled.render_around_insertion_point(0, &crate::DefaultPrompt::default(), false, None);
assert_eq!((left.as_str(), right.starts_with(' ')), ("", false));
let fg = Style::new().fg(Color::Green);
let out = super::render_as_string(&(fg, "\n".to_string()), &Style::new(), "> ", None);
assert_eq!(strip_ansi(&out), "\n> ");
}
#[test]
fn an_empty_chunk_is_not_given_a_cell() {
let style = Style::new().on(Color::LightGray);
let result = super::render_as_string(&(style, String::new()), &Style::new(), "::: ", None);
assert_eq!(strip_ansi(&result), "");
}
#[test]
fn selecting_only_the_newline_paints_a_cell_end_to_end() {
let sel = Style::new().on(Color::LightGray);
let mut styled = StyledText {
buffer: vec![(Style::new(), "ab\ncd".into())],
};
styled.style_range(2, 3, sel);
assert_eq!(styled.buffer[1], (sel, "\n".into()));
let rendered: String = styled
.buffer
.iter()
.map(|p| super::render_as_string(p, &Style::new(), "> ", None))
.collect();
assert_eq!(strip_ansi(&rendered), "ab \n> cd");
}
}