Folderize painting code

- Move painter and styled_text into a common folder
- Separate out `PromptLines` and freestanding utility functions
This commit is contained in:
sholderbach 2022-03-14 15:10:51 +01:00 committed by Stefan Holderbach
parent ad4760e476
commit 5bc9d6c747
11 changed files with 232 additions and 211 deletions

View File

@ -10,7 +10,7 @@ use {
hinter::{DefaultHinter, Hinter},
history::{FileBackedHistory, History, HistoryNavigationQuery},
menu::{parse_selection_char, Menu, MenuEvent},
painter::{Painter, PromptLines},
painting::{Painter, PromptLines},
prompt::{PromptEditMode, PromptHistorySearchStatus},
text_manipulation, DefaultValidator, EditCommand, ExampleHighlighter, Highlighter, Prompt,
PromptHistorySearch, Signal, ValidationResult, Validator,

View File

@ -1,7 +1,7 @@
mod example;
mod simple_match;
use crate::styled_text::StyledText;
use crate::StyledText;
pub use example::ExampleHighlighter;
pub use simple_match::SimpleMatchHighlighter;

View File

@ -185,7 +185,8 @@ mod text_manipulation;
mod enums;
pub use enums::{EditCommand, ReedlineEvent, Signal, UndoBehavior};
mod painter;
mod painting;
pub use painting::StyledText;
mod engine;
pub use engine::Reedline;
@ -208,9 +209,6 @@ pub use edit_mode::{
mod highlighter;
pub use highlighter::{ExampleHighlighter, Highlighter, SimpleMatchHighlighter};
mod styled_text;
pub use styled_text::StyledText;
mod completion;
pub use completion::{Completer, DefaultCompleter, Span};

View File

@ -1,5 +1,5 @@
use super::{find_common_string, Menu, MenuEvent, MenuTextStyle};
use crate::{painter::Painter, Completer, History, LineBuffer, Span};
use crate::{painting::Painter, Completer, History, LineBuffer, Span};
use nu_ansi_term::{ansi::RESET, Style};
/// Default values used as reference for the menu. These values are set during

View File

@ -1,6 +1,6 @@
use super::{parse_selection_char, Menu, MenuEvent, MenuTextStyle};
use crate::{
painter::{estimate_single_line_wraps, Painter},
painting::{estimate_single_line_wraps, Painter},
Completer, History, LineBuffer, Span,
};
use nu_ansi_term::{ansi::RESET, Style};

View File

@ -1,7 +1,7 @@
mod completion_menu;
mod history_menu;
use crate::{painter::Painter, Completer, History, LineBuffer, Span};
use crate::{painting::Painter, Completer, History, LineBuffer, Span};
pub use completion_menu::CompletionMenu;
pub use history_menu::HistoryMenu;
use nu_ansi_term::{Color, Style};

9
src/painting/mod.rs Normal file
View File

@ -0,0 +1,9 @@
mod painter;
mod prompt_lines;
mod styled_text;
mod utils;
pub use painter::Painter;
pub(crate) use prompt_lines::PromptLines;
pub use styled_text::StyledText;
pub(crate) use utils::estimate_single_line_wraps;

View File

@ -1,158 +1,16 @@
use super::utils::{coerce_crlf, line_width};
use {
crate::{
menu::Menu, prompt::PromptEditMode, styled_text::strip_ansi, Prompt, PromptHistorySearch,
},
crate::{menu::Menu, painting::PromptLines, Prompt},
crossterm::{
cursor::{self, MoveTo, RestorePosition, SavePosition},
style::{Print, ResetColor, SetForegroundColor},
terminal::{self, Clear, ClearType, ScrollUp},
QueueableCommand, Result,
},
std::borrow::Cow,
std::io::Write,
unicode_width::UnicodeWidthStr,
};
pub struct PromptLines<'prompt> {
prompt_str_left: Cow<'prompt, str>,
prompt_str_right: Cow<'prompt, str>,
prompt_indicator: Cow<'prompt, str>,
before_cursor: Cow<'prompt, str>,
after_cursor: Cow<'prompt, str>,
hint: Cow<'prompt, str>,
}
impl<'prompt> PromptLines<'prompt> {
/// Splits the strings before and after the cursor as well as the hint
/// This vector with the str are used to calculate how many lines are
/// required to print after the prompt
pub fn new(
prompt: &'prompt dyn Prompt,
prompt_mode: PromptEditMode,
history_indicator: Option<PromptHistorySearch>,
before_cursor: &'prompt str,
after_cursor: &'prompt str,
hint: &'prompt str,
) -> Self {
let prompt_str_left = prompt.render_prompt_left();
let prompt_str_right = prompt.render_prompt_right();
let prompt_indicator = match history_indicator {
Some(prompt_search) => prompt.render_prompt_history_search_indicator(prompt_search),
None => prompt.render_prompt_indicator(prompt_mode),
};
let before_cursor = coerce_crlf(before_cursor);
let after_cursor = coerce_crlf(after_cursor);
let hint = coerce_crlf(hint);
Self {
prompt_str_left,
prompt_str_right,
prompt_indicator,
before_cursor,
after_cursor,
hint,
}
}
/// The required lines to paint the buffer are calculated by counting the
/// number of newlines in all the strings that form the prompt and buffer.
/// The plus 1 is to indicate that there should be at least one line.
fn required_lines(&self, terminal_columns: u16, menu: Option<&dyn Menu>) -> u16 {
let input = if menu.is_none() {
self.prompt_str_left.to_string()
+ &self.prompt_indicator
+ &self.before_cursor
+ &self.after_cursor
+ &self.hint
} else {
self.prompt_str_left.to_string()
+ &self.prompt_indicator
+ &self.before_cursor
+ &self.after_cursor
};
let lines = estimate_required_lines(&input, terminal_columns);
if let Some(menu) = menu {
lines as u16 + menu.menu_required_lines(terminal_columns)
} else {
lines as u16
}
}
/// Estimated distance of the cursor to the prompt.
/// This considers line wrapping
fn distance_from_prompt(&self, terminal_columns: u16) -> u16 {
let input = self.prompt_str_left.to_string() + &self.prompt_indicator + &self.before_cursor;
let lines = estimate_required_lines(&input, terminal_columns);
lines.saturating_sub(1) as u16
}
/// Total lines that the prompt uses considering that it may wrap the screen
fn prompt_lines_with_wrap(&self, screen_width: u16) -> u16 {
let complete_prompt = self.prompt_str_left.to_string() + &self.prompt_indicator;
let lines = estimate_required_lines(&complete_prompt, screen_width);
lines.saturating_sub(1) as u16
}
/// Estimated width of the actual input
fn estimate_first_input_line_width(&self) -> u16 {
let last_line_left_prompt = self.prompt_str_left.lines().last();
let prompt_lines_total = self.before_cursor.to_string() + &self.after_cursor + &self.hint;
let prompt_lines_first = prompt_lines_total.lines().next();
let mut estimate = 0; // space in front of the input
if let Some(last_line_left_prompt) = last_line_left_prompt {
estimate += line_width(last_line_left_prompt);
}
estimate += line_width(&self.prompt_indicator);
if let Some(prompt_lines_first) = prompt_lines_first {
estimate += line_width(prompt_lines_first);
}
if estimate > u16::MAX as usize {
u16::MAX
} else {
estimate as u16
}
}
}
pub(crate) fn estimate_required_lines(input: &str, screen_width: u16) -> usize {
input.lines().fold(0, |acc, line| {
let wrap = estimate_single_line_wraps(line, screen_width);
acc + 1 + wrap
})
}
/// Reports the additional lines needed due to wrapping for the given line.
///
/// Does not account for any potential linebreaks in `line`
///
/// If `line` fits in `terminal_columns` returns 0
pub(crate) fn estimate_single_line_wraps(line: &str, terminal_columns: u16) -> usize {
let estimated_width = line_width(line);
let terminal_columns: usize = terminal_columns.into();
// integer ceiling rounding division for positive divisors
let estimated_line_count = (estimated_width + terminal_columns - 1) / terminal_columns;
// Any wrapping will add to our overall line count
estimated_line_count.saturating_sub(1)
}
/// Compute the line width for ANSI escaped text
fn line_width(line: &str) -> usize {
strip_ansi(line).width()
}
// Returns a string that skips N number of lines with the next offset of lines
// An offset of 0 would return only one line after skipping the required lines
fn skip_buffer_lines(string: &str, skip: usize, offset: Option<usize>) -> &str {
@ -181,33 +39,6 @@ fn skip_buffer_lines(string: &str, skip: usize, offset: Option<usize>) -> &str {
string[index..limit].trim_end_matches('\n')
}
fn coerce_crlf(input: &str) -> Cow<str> {
let mut result = Cow::Borrowed(input);
let mut cursor: usize = 0;
for (idx, _) in input.match_indices('\n') {
if !(idx > 0 && input.as_bytes()[idx - 1] == b'\r') {
if let Cow::Borrowed(_) = result {
// Best case 1 allocation, worst case 2 allocations
let mut owned = String::with_capacity(input.len() + 1);
// Optimization to avoid the `AddAssign for Cow<str>`
// optimization for `Cow<str>.is_empty` that would replace the
// preallocation
owned.push_str(&input[cursor..idx]);
result = Cow::Owned(owned);
} else {
result += &input[cursor..idx];
}
result += "\r\n";
// Advance beyond the matched LF char (single byte)
cursor = idx + 1;
}
}
if let Cow::Owned(_) = result {
result += &input[cursor..input.len()];
}
result
}
/// the type used by crossterm operations
pub type W = std::io::BufWriter<std::io::Stderr>;
@ -594,7 +425,6 @@ impl Painter {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test]
fn test_skip_lines() {
@ -646,23 +476,4 @@ mod tests {
assert_eq!(skip_buffer_lines(string, 0, Some(0)), "sentence1",);
assert_eq!(skip_buffer_lines(string, 1, Some(0)), "sentence2",);
}
#[rstest]
#[case("sentence\nsentence", "sentence\r\nsentence")]
#[case("sentence\r\nsentence", "sentence\r\nsentence")]
#[case("sentence\nsentence\n", "sentence\r\nsentence\r\n")]
#[case("😇\nsentence", "😇\r\nsentence")]
#[case("sentence\n😇", "sentence\r\n😇")]
#[case("\n", "\r\n")]
#[case("", "")]
fn test_coerce_crlf(#[case] input: &str, #[case] expected: &str) {
let result = coerce_crlf(input);
assert_eq!(result, expected);
assert!(
input != expected || matches!(result, Cow::Borrowed(_)),
"Unnecessary allocation"
)
}
}

View File

@ -0,0 +1,114 @@
use super::utils::{coerce_crlf, estimate_required_lines, line_width};
use crate::{menu::Menu, prompt::PromptEditMode, Prompt, PromptHistorySearch};
use std::borrow::Cow;
pub struct PromptLines<'prompt> {
pub(crate) prompt_str_left: Cow<'prompt, str>,
pub(crate) prompt_str_right: Cow<'prompt, str>,
pub(crate) prompt_indicator: Cow<'prompt, str>,
pub(crate) before_cursor: Cow<'prompt, str>,
pub(crate) after_cursor: Cow<'prompt, str>,
pub(crate) hint: Cow<'prompt, str>,
}
impl<'prompt> PromptLines<'prompt> {
/// Splits the strings before and after the cursor as well as the hint
/// This vector with the str are used to calculate how many lines are
/// required to print after the prompt
pub fn new(
prompt: &'prompt dyn Prompt,
prompt_mode: PromptEditMode,
history_indicator: Option<PromptHistorySearch>,
before_cursor: &'prompt str,
after_cursor: &'prompt str,
hint: &'prompt str,
) -> Self {
let prompt_str_left = prompt.render_prompt_left();
let prompt_str_right = prompt.render_prompt_right();
let prompt_indicator = match history_indicator {
Some(prompt_search) => prompt.render_prompt_history_search_indicator(prompt_search),
None => prompt.render_prompt_indicator(prompt_mode),
};
let before_cursor = coerce_crlf(before_cursor);
let after_cursor = coerce_crlf(after_cursor);
let hint = coerce_crlf(hint);
Self {
prompt_str_left,
prompt_str_right,
prompt_indicator,
before_cursor,
after_cursor,
hint,
}
}
/// The required lines to paint the buffer are calculated by counting the
/// number of newlines in all the strings that form the prompt and buffer.
/// The plus 1 is to indicate that there should be at least one line.
pub(crate) fn required_lines(&self, terminal_columns: u16, menu: Option<&dyn Menu>) -> u16 {
let input = if menu.is_none() {
self.prompt_str_left.to_string()
+ &self.prompt_indicator
+ &self.before_cursor
+ &self.after_cursor
+ &self.hint
} else {
self.prompt_str_left.to_string()
+ &self.prompt_indicator
+ &self.before_cursor
+ &self.after_cursor
};
let lines = estimate_required_lines(&input, terminal_columns);
if let Some(menu) = menu {
lines as u16 + menu.menu_required_lines(terminal_columns)
} else {
lines as u16
}
}
/// Estimated distance of the cursor to the prompt.
/// This considers line wrapping
pub(crate) fn distance_from_prompt(&self, terminal_columns: u16) -> u16 {
let input = self.prompt_str_left.to_string() + &self.prompt_indicator + &self.before_cursor;
let lines = estimate_required_lines(&input, terminal_columns);
lines.saturating_sub(1) as u16
}
/// Total lines that the prompt uses considering that it may wrap the screen
pub(crate) fn prompt_lines_with_wrap(&self, screen_width: u16) -> u16 {
let complete_prompt = self.prompt_str_left.to_string() + &self.prompt_indicator;
let lines = estimate_required_lines(&complete_prompt, screen_width);
lines.saturating_sub(1) as u16
}
/// Estimated width of the actual input
pub(crate) fn estimate_first_input_line_width(&self) -> u16 {
let last_line_left_prompt = self.prompt_str_left.lines().last();
let prompt_lines_total = self.before_cursor.to_string() + &self.after_cursor + &self.hint;
let prompt_lines_first = prompt_lines_total.lines().next();
let mut estimate = 0; // space in front of the input
if let Some(last_line_left_prompt) = last_line_left_prompt {
estimate += line_width(last_line_left_prompt);
}
estimate += line_width(&self.prompt_indicator);
if let Some(prompt_lines_first) = prompt_lines_first {
estimate += line_width(prompt_lines_first);
}
if estimate > u16::MAX as usize {
u16::MAX
} else {
estimate as u16
}
}
}

View File

@ -1,3 +1,4 @@
use super::utils::strip_ansi;
use nu_ansi_term::{Color, Style};
/// A representation of a buffer with styling, used for doing syntax highlighting
@ -86,16 +87,6 @@ impl StyledText {
}
}
/// Returns string with the ANSI escape codes removed
///
/// If parsing fails silently returns the input string
pub(crate) fn strip_ansi(string: &str) -> String {
strip_ansi_escapes::strip(string)
.map_err(|_| ())
.and_then(|x| String::from_utf8(x).map_err(|_| ()))
.unwrap_or_else(|_| string.to_owned())
}
fn render_as_string(
renderable: &(Style, String),
prompt_style: &Style,

98
src/painting/utils.rs Normal file
View File

@ -0,0 +1,98 @@
use std::borrow::Cow;
use unicode_width::UnicodeWidthStr;
/// Ensures input uses CRLF line endings.
///
/// Needed for correct output in raw mode.
/// Only replaces solitary LF with CRLF.
pub(crate) fn coerce_crlf(input: &str) -> Cow<str> {
let mut result = Cow::Borrowed(input);
let mut cursor: usize = 0;
for (idx, _) in input.match_indices('\n') {
if !(idx > 0 && input.as_bytes()[idx - 1] == b'\r') {
if let Cow::Borrowed(_) = result {
// Best case 1 allocation, worst case 2 allocations
let mut owned = String::with_capacity(input.len() + 1);
// Optimization to avoid the `AddAssign for Cow<str>`
// optimization for `Cow<str>.is_empty` that would replace the
// preallocation
owned.push_str(&input[cursor..idx]);
result = Cow::Owned(owned);
} else {
result += &input[cursor..idx];
}
result += "\r\n";
// Advance beyond the matched LF char (single byte)
cursor = idx + 1;
}
}
if let Cow::Owned(_) = result {
result += &input[cursor..input.len()];
}
result
}
/// Returns string with the ANSI escape codes removed
///
/// If parsing fails silently returns the input string
pub(crate) fn strip_ansi(string: &str) -> String {
strip_ansi_escapes::strip(string)
.map_err(|_| ())
.and_then(|x| String::from_utf8(x).map_err(|_| ()))
.unwrap_or_else(|_| string.to_owned())
}
pub(crate) fn estimate_required_lines(input: &str, screen_width: u16) -> usize {
input.lines().fold(0, |acc, line| {
let wrap = estimate_single_line_wraps(line, screen_width);
acc + 1 + wrap
})
}
/// Reports the additional lines needed due to wrapping for the given line.
///
/// Does not account for any potential linebreaks in `line`
///
/// If `line` fits in `terminal_columns` returns 0
pub(crate) fn estimate_single_line_wraps(line: &str, terminal_columns: u16) -> usize {
let estimated_width = line_width(line);
let terminal_columns: usize = terminal_columns.into();
// integer ceiling rounding division for positive divisors
let estimated_line_count = (estimated_width + terminal_columns - 1) / terminal_columns;
// Any wrapping will add to our overall line count
estimated_line_count.saturating_sub(1)
}
/// Compute the line width for ANSI escaped text
pub(crate) fn line_width(line: &str) -> usize {
strip_ansi(line).width()
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
#[case("sentence\nsentence", "sentence\r\nsentence")]
#[case("sentence\r\nsentence", "sentence\r\nsentence")]
#[case("sentence\nsentence\n", "sentence\r\nsentence\r\n")]
#[case("😇\nsentence", "😇\r\nsentence")]
#[case("sentence\n😇", "sentence\r\n😇")]
#[case("\n", "\r\n")]
#[case("", "")]
fn test_coerce_crlf(#[case] input: &str, #[case] expected: &str) {
let result = coerce_crlf(input);
assert_eq!(result, expected);
assert!(
input != expected || matches!(result, Cow::Borrowed(_)),
"Unnecessary allocation"
)
}
}