/*
* Copyright (c) 2023 R3BL LLC
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
use std::io::{Result, Write};
use crossterm::{cursor::{MoveToColumn, MoveToNextLine, MoveToPreviousLine},
queue,
style::{Attribute,
Print,
ResetColor,
SetBackgroundColor,
SetForegroundColor,
Stylize},
terminal::{Clear, ClearType}};
use r3bl_ansi_color::AnsiStyledText;
use r3bl_core::{call_if_true, ch, get_terminal_width, throws, ChUnit, UnicodeString};
use crate::{apply_style,
get_crossterm_color_based_on_terminal_capabilities,
set_attribute,
FunctionComponent,
Header,
SelectionMode,
State,
StyleSheet,
DEVELOPMENT_MODE};
pub struct SelectComponent<W: Write> {
pub write: W,
pub style: StyleSheet,
}
const IS_FOCUSED: &str = " › ";
const IS_NOT_FOCUSED: &str = " ";
const MULTI_SELECT_IS_SELECTED: &str = "✔";
const MULTI_SELECT_IS_NOT_SELECTED: &str = "☐";
const SINGLE_SELECT_IS_SELECTED: &str = "◉";
const SINGLE_SELECT_IS_NOT_SELECTED: &str = "◌";
impl<W: Write> FunctionComponent<W, State<'_>> for SelectComponent<W> {
fn get_write(&mut self) -> &mut W { &mut self.write }
// Header can be either a single line or a multi line.
fn calculate_header_viewport_height(&self, state: &mut State<'_>) -> ChUnit {
match state.get_header() {
Header::Single => ch!(1),
Header::Multiple => ch!(state.multi_line_header.len()),
}
}
/// If there are more items than the max display height, then we only use max display
/// height. Otherwise we can shrink the display height to the number of items.
/// This does NOT include the header.
fn calculate_items_viewport_height(&self, state: &mut State<'_>) -> ChUnit {
if state.items.len() > ch!(@to_usize state.max_display_height) {
state.max_display_height
} else {
ch!(state.items.len())
}
}
/// Allocate space and print the lines. The bring the cursor back to the start of the
/// lines.
fn render(&mut self, state: &mut State<'_>) -> Result<()> {
throws!({
// Setup the required data.
let focused_and_selected_style = self.style.focused_and_selected_style;
let focused_style = self.style.focused_style;
let unselected_style = self.style.unselected_style;
let selected_style = self.style.selected_style;
let single_line_header_style = self.style.header_style;
let start_display_col_offset = 1;
let header_viewport_height: ChUnit =
self.calculate_header_viewport_height(state);
// If there are more items than the max display height, then we only use max
// display height. Otherwise we can shrink the display height to the number of
// items.
let items_viewport_height: ChUnit =
self.calculate_items_viewport_height(state);
let viewport_width: ChUnit = {
// Try to get the terminal width from state first (since it should be set
// when resize events occur). If that is not set, then get the terminal
// width directly.
let terminal_width = match state.window_size {
Some(size) => size.col_count,
None => ch!(get_terminal_width()),
};
// Do not exceed the max display width (if it is set).
if state.max_display_width == ch!(0)
|| state.max_display_width > ch!(terminal_width)
{
ch!(terminal_width)
} else {
state.max_display_width
}
};
call_if_true!(DEVELOPMENT_MODE, {
tracing::debug!(
"🍎🍎🍎\n render()::state: \n\t[raw_caret_row_index: {}, scroll_offset_row_index: {}], \n\theader_viewport_height: {}, items_viewport_height:{}, viewport_width:{}",
format!("{}", state.raw_caret_row_index).blue(),
format!("{}", state.scroll_offset_row_index).blue(),
format!("{}", header_viewport_height).blue(),
format!("{}", items_viewport_height).blue(),
format!("{}", viewport_width).blue()
);
});
self.allocate_viewport_height_space(state)?;
let data_row_index_start = *state.scroll_offset_row_index;
let writer = self.get_write();
match state.get_header() {
Header::Single => {
let mut header_text = format!(
"{}{}",
" ".repeat(start_display_col_offset),
state.header
);
header_text =
clip_string_to_width_with_ellipsis(header_text, viewport_width);
queue! {
writer,
// Bring the caret back to the start of line.
MoveToColumn(0),
// Reset the colors that may have been set by the previous command.
ResetColor,
// Set the colors for the text.
apply_style!(single_line_header_style => fg_color),
apply_style!(single_line_header_style => bg_color),
// Style the text.
apply_style!(single_line_header_style => bold),
apply_style!(single_line_header_style => italic),
apply_style!(single_line_header_style => dim),
apply_style!(single_line_header_style => underline),
apply_style!(single_line_header_style => reverse),
apply_style!(single_line_header_style => hidden),
apply_style!(single_line_header_style => strikethrough),
// Clear the current line.
Clear(ClearType::CurrentLine),
// Print the text.
Print(header_text),
// Move to next line.
MoveToNextLine(1),
// Reset the colors.
ResetColor,
}?;
}
Header::Multiple => {
// Subtract 3 from viewport width because we need to add "..." to the
// end of the line.
let mut available_space_col_count: ChUnit = viewport_width - 3;
// This is the vector of vectors of AnsiStyledText we want to print to
// the screen.
let mut multi_line_header_clipped_vec: Vec<Vec<AnsiStyledText<'_>>> =
Vec::new();
let mut maybe_clipped_text_vec: Vec<Vec<String>> = Vec::new();
for header_line in state.multi_line_header.iter() {
let mut header_line_modified = vec![];
'inner: for last_span in header_line.iter() {
let span_text = last_span.text;
let span_as_unicode_string = UnicodeString::from(span_text);
let unicode_string_width =
span_as_unicode_string.display_width;
if unicode_string_width > available_space_col_count {
// Clip the text to available space.
let clipped_text = span_as_unicode_string
.clip_to_width(ch!(0), available_space_col_count);
let clipped_text = format!("{clipped_text}...");
header_line_modified.push(clipped_text.to_owned());
break 'inner;
} else {
available_space_col_count -= unicode_string_width;
// If last item in the header, then fill the remaining
// space with spaces.
let maybe_header_line_last_span: Option<
&AnsiStyledText<'_>,
> = header_line.last();
if let Some(header_line_last_span) =
maybe_header_line_last_span
{
if last_span == header_line_last_span {
// Because text is not clipped, we add back the 3 we subtracted
// earlier for the "...".
let num_of_spaces: ChUnit =
available_space_col_count + ch!(3);
let span_with_spaces = span_text.to_owned()
+ &" ".repeat(num_of_spaces.into());
header_line_modified.push(span_with_spaces);
} else {
header_line_modified.push(span_text.to_owned());
}
}
};
}
// Reset the available space.
available_space_col_count = viewport_width - 3;
maybe_clipped_text_vec.push(header_line_modified);
}
// Replace the text inside vector of vectors of AnsiStyledText with
// the clipped text.
let zipped = maybe_clipped_text_vec
.iter()
.zip(state.multi_line_header.iter());
zipped.for_each(|(clipped_text_vec, header_span_vec)| {
let mut ansi_styled_text_vec: Vec<AnsiStyledText<'_>> =
Vec::new();
let zipped = clipped_text_vec.iter().zip(header_span_vec.iter());
zipped.for_each(|(clipped_text, header_span)| {
ansi_styled_text_vec.push(AnsiStyledText {
text: clipped_text,
style: header_span.style,
});
});
multi_line_header_clipped_vec.push(ansi_styled_text_vec);
});
let multi_line_header_text = multi_line_header_clipped_vec
.iter()
.map(|header_line| {
header_line
.iter()
.map(|header_span| header_span.to_string())
.collect::<Vec<String>>()
.join("")
})
.collect::<Vec<String>>()
.join("\r\n");
queue! {
writer,
// Bring the caret back to the start of line.
MoveToColumn(0),
// Reset the colors that may have been set by the previous command.
ResetColor,
// Clear the current line.
Clear(ClearType::CurrentLine),
// Print each AnsiStyledText.
Print(multi_line_header_text),
// Move to next line.
MoveToNextLine(1),
// Reset the colors.
ResetColor,
}?;
}
}
// Print each line in viewport.
for viewport_row_index in 0..*items_viewport_height {
let data_row_index: usize =
(data_row_index_start + viewport_row_index).into();
let caret_row_scroll_adj =
ch!(viewport_row_index) + state.scroll_offset_row_index;
let data_item = &state.items[data_row_index];
// Invert colors for selected items.
enum SelectionStateStyle {
FocusedAndSelected,
Focused,
Selected,
Unselected,
}
let is_selected = state.selected_items.contains(data_item);
let is_focused = ch!(caret_row_scroll_adj) == state.get_focused_index();
let selection_state = match (is_focused, is_selected) {
(true, true) => SelectionStateStyle::FocusedAndSelected,
(true, false) => SelectionStateStyle::Focused,
(false, true) => SelectionStateStyle::Selected,
(false, false) => SelectionStateStyle::Unselected,
};
let data_style = match selection_state {
SelectionStateStyle::FocusedAndSelected => focused_and_selected_style,
SelectionStateStyle::Focused => focused_style,
SelectionStateStyle::Selected => selected_style,
SelectionStateStyle::Unselected => unselected_style,
};
let row_prefix = match state.selection_mode {
SelectionMode::Single => {
let padding_left = " ".repeat(start_display_col_offset);
if is_focused {
format!("{padding_left} {SINGLE_SELECT_IS_SELECTED} ")
} else {
format!("{padding_left} {SINGLE_SELECT_IS_NOT_SELECTED} ")
}
}
SelectionMode::Multiple => {
let padding_left = " ".repeat(start_display_col_offset);
match (is_focused, is_selected) {
(true, true) => {
format!("{padding_left} {IS_FOCUSED} {MULTI_SELECT_IS_SELECTED} ")
}
(true, false) => format!(
"{padding_left} {IS_FOCUSED} {MULTI_SELECT_IS_NOT_SELECTED} "
),
(false, true) => format!(
"{padding_left} {IS_NOT_FOCUSED} {MULTI_SELECT_IS_SELECTED} "
),
(false, false) => format!(
"{padding_left} {IS_NOT_FOCUSED} {MULTI_SELECT_IS_NOT_SELECTED} "
),
}
}
};
let data_item = format!("{row_prefix}{data_item}");
let data_item: String =
clip_string_to_width_with_ellipsis(data_item, viewport_width);
let data_item_display_width: ChUnit =
UnicodeString::from(&data_item).display_width;
let padding_right = if data_item_display_width < viewport_width {
" ".repeat(ch!(@to_usize (viewport_width - data_item_display_width)))
} else {
"".to_string()
};
queue! {
writer,
// Bring the caret back to the start of line.
MoveToColumn(0),
// Reset the colors that may have been set by the previous command.
ResetColor,
// Clear the current line.
Clear(ClearType::CurrentLine),
// Set the colors for the text.
apply_style!(data_style => fg_color),
apply_style!(data_style => bg_color),
// Style the text.
apply_style!(data_style => bold),
apply_style!(data_style => italic),
apply_style!(data_style => dim),
apply_style!(data_style => underline),
apply_style!(data_style => reverse),
apply_style!(data_style => hidden),
apply_style!(data_style => strikethrough),
// Print the text.
Print(data_item),
// Print the padding text.
Print(padding_right),
// Move to next line.
MoveToNextLine(1),
// Reset the colors.
ResetColor,
}?;
}
// Move the cursor back up.
queue! {
writer,
MoveToPreviousLine(*items_viewport_height + *header_viewport_height),
}?;
writer.flush()?;
});
}
}
pub fn clip_string_to_width_with_ellipsis(
mut header_text: String,
viewport_width: ChUnit,
) -> String {
let unicode_string = UnicodeString::from(header_text);
let unicode_string_width = unicode_string.display_width;
let available_space_col_count: ChUnit = viewport_width;
if unicode_string_width > available_space_col_count {
// Clip the text to available space.
let clipped_text =
unicode_string.clip_to_width(ch!(0), available_space_col_count - 3);
let clipped_text = format!("{clipped_text}...");
header_text = clipped_text;
} else {
header_text = unicode_string.string;
}
header_text
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use r3bl_ansi_color::global_color_support::{clear_override, set_override};
use serial_test::serial;
use super::*;
use crate::TestStringWriter;
#[test]
fn test_clip_string_to_width_with_ellipsis() {
let line = "This is a long line that needs to be clipped".to_string();
let clipped_line =
clip_string_to_width_with_ellipsis(line.clone(), ChUnit::new(20));
assert_eq!(clipped_line, "This is a long li...");
let short_line = "This is a short line".to_string();
let clipped_short_line =
clip_string_to_width_with_ellipsis(short_line.clone(), ChUnit::new(20));
assert_eq!(clipped_short_line, "This is a short line");
}
#[serial]
#[test]
fn test_select_component() {
let mut state = State {
header: "Header".to_string(),
items: vec![
"Item 1".to_string(),
"Item 2".to_string(),
"Item 3".to_string(),
],
max_display_height: ch!(5),
max_display_width: ch!(40),
raw_caret_row_index: ch!(0),
scroll_offset_row_index: ch!(0),
selected_items: vec![],
selection_mode: SelectionMode::Single,
..Default::default()
};
state.scroll_offset_row_index = ch!(0);
let mut writer = TestStringWriter::new();
let mut component = SelectComponent {
write: &mut writer,
style: StyleSheet::default(),
};
set_override(r3bl_ansi_color::ColorSupport::Ansi256);
component.render(&mut state).unwrap();
let generated_output = writer.get_buffer().to_string();
println!(
"generated_output = writer.get_buffer(): \n\n{:#?}\n\n",
generated_output
);
let expected_output = "\u{1b}[4F\u{1b}[1G\u{1b}[0m\u{1b}[38;5;153m\u{1b}[48;5;235m\u{1b}[21m\u{1b}[23m\u{1b}[22m\u{1b}[24m\u{1b}[27m\u{1b}[28m\u{1b}[29m\u{1b}[2K Header\u{1b}[1E\u{1b}[0m\u{1b}[1G\u{1b}[0m\u{1b}[2K\u{1b}[38;5;46m\u{1b}[48;5;233m\u{1b}[21m\u{1b}[23m\u{1b}[22m\u{1b}[24m\u{1b}[27m\u{1b}[28m\u{1b}[29m ◉ Item 1 \u{1b}[1E\u{1b}[0m\u{1b}[1G\u{1b}[0m\u{1b}[2K\u{1b}[38;5;250m\u{1b}[48;5;233m\u{1b}[21m\u{1b}[23m\u{1b}[22m\u{1b}[24m\u{1b}[27m\u{1b}[28m\u{1b}[29m ◌ Item 2 \u{1b}[1E\u{1b}[0m\u{1b}[1G\u{1b}[0m\u{1b}[2K\u{1b}[38;5;250m\u{1b}[48;5;233m\u{1b}[21m\u{1b}[23m\u{1b}[22m\u{1b}[24m\u{1b}[27m\u{1b}[28m\u{1b}[29m ◌ Item 3 \u{1b}[1E\u{1b}[0m\u{1b}[4F";
assert_eq!(generated_output, expected_output);
clear_override();
}
}