use crate::{ChUnit, CliTextInline, CommonResult, DEVELOPMENT_MODE, FunctionComponent,
GCStringOwned, Header, HowToChoose, InlineString, InlineVec, OutputDevice,
State, StyleSheet, TuiStyle, ch, cli_text_inline, col,
core::common::string_repeat_cache::get_spaces, fg_blue, get_terminal_width,
inline_string, lock_output_device_as_mut, queue_commands, usize, width};
use crossterm::{cursor::{MoveToColumn, MoveToNextLine, MoveToPreviousLine},
style::{Print, ResetColor},
terminal::{Clear, ClearType}};
use miette::IntoDiagnostic;
#[allow(missing_debug_implementations)]
pub struct SelectComponent {
pub output_device: OutputDevice,
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 FunctionComponent<State> for SelectComponent {
fn get_output_device(&mut self) -> OutputDevice { self.output_device.clone() }
fn calculate_header_viewport_height(&self, state: &mut State) -> ChUnit {
match state.header {
Header::SingleLine(_) => ch(1),
Header::MultiLine(ref lines) => ch(lines.len()),
}
}
fn calculate_items_viewport_height(&self, state: &mut State) -> ChUnit {
if state.items.len() > usize(state.max_display_height) {
state.max_display_height
} else {
ch(state.items.len())
}
}
fn render(&mut self, state: &mut State) -> CommonResult<()> {
let render_context = render_helper::RenderContext::new(self, state);
render_helper::log_render_debug_info(state, &render_context);
self.allocate_viewport_height_space(state)?;
render_helper::render_header(
&mut self.output_device,
&state.header,
&self.style.header_style,
render_context.viewport_width,
render_context.start_display_col_offset,
)?;
render_helper::render_items(
&mut self.output_device,
state,
&self.style,
&render_context,
)?;
render_helper::move_cursor_back_to_start(
&mut self.output_device,
render_context.items_viewport_height,
render_context.header_viewport_height,
)?;
lock_output_device_as_mut!(self.output_device)
.flush()
.into_diagnostic()?;
Ok(())
}
}
mod render_helper {
use super::{ChUnit, Clear, ClearType, CliTextInline, CommonResult, DEVELOPMENT_MODE,
FunctionComponent, GCStringOwned, Header, HowToChoose, IS_FOCUSED,
IS_NOT_FOCUSED, InlineString, InlineVec, MULTI_SELECT_IS_NOT_SELECTED,
MULTI_SELECT_IS_SELECTED, MoveToColumn, MoveToNextLine,
MoveToPreviousLine, OutputDevice, Print, ResetColor,
SINGLE_SELECT_IS_NOT_SELECTED, SINGLE_SELECT_IS_SELECTED,
SelectComponent, State, StyleSheet, TuiStyle, ch, cli_text_inline,
clip_string_to_width_with_ellipsis, col, fg_blue, get_spaces,
get_terminal_width, inline_string, queue_commands, usize, width};
pub struct RenderContext {
pub header_viewport_height: ChUnit,
pub items_viewport_height: ChUnit,
pub viewport_width: ChUnit,
pub start_display_col_offset: usize,
pub data_row_index_start: ChUnit,
}
impl RenderContext {
pub fn new(component: &SelectComponent, state: &mut State) -> Self {
let header_viewport_height =
component.calculate_header_viewport_height(state);
let items_viewport_height = component.calculate_items_viewport_height(state);
let viewport_width = calculate_viewport_width(state);
let start_display_col_offset = 1;
let data_row_index_start = state.scroll_offset_row_index;
Self {
header_viewport_height,
items_viewport_height,
viewport_width,
start_display_col_offset,
data_row_index_start,
}
}
}
pub fn calculate_viewport_width(state: &State) -> ChUnit {
let terminal_width = *match state.window_size {
Some(size) => size.col_width,
None => get_terminal_width(),
};
if state.max_display_width == ch(0)
|| state.max_display_width > ch(terminal_width)
{
ch(terminal_width)
} else {
state.max_display_width
}
}
pub fn log_render_debug_info(state: &State, render_context: &RenderContext) {
DEVELOPMENT_MODE.then(|| {
tracing::info! {
message = "🍎🍎🍎\n render()::state",
details = %inline_string!(
"\t[raw_caret_row_index: {a}, scroll_offset_row_index: {b}], \n\theader_viewport_height: {c}, items_viewport_height:{d}, viewport_width:{e}",
a = fg_blue(&inline_string!("{:?}", state.raw_caret_row_index)),
b = fg_blue(&inline_string!("{:?}", state.scroll_offset_row_index)),
c = fg_blue(&inline_string!("{:?}", render_context.header_viewport_height)),
d = fg_blue(&inline_string!("{:?}", render_context.items_viewport_height)),
e = fg_blue(&inline_string!("{:?}", render_context.viewport_width)),
)
};
});
}
pub fn render_header(
output_device: &mut OutputDevice,
header: &Header,
header_style: &TuiStyle,
viewport_width: ChUnit,
start_display_col_offset: usize,
) -> CommonResult<()> {
match header {
Header::SingleLine(header_text) => render_single_line_header(
output_device,
header_text,
header_style,
viewport_width,
start_display_col_offset,
),
Header::MultiLine(header_lines) => {
render_multi_line_header(output_device, header_lines, viewport_width)
}
}
}
fn render_single_line_header(
output_device: &mut OutputDevice,
header_text: &str,
header_style: &TuiStyle,
viewport_width: ChUnit,
start_display_col_offset: usize,
) -> CommonResult<()> {
let mut header_text =
format!("{}{}", &get_spaces(start_display_col_offset), header_text);
header_text = clip_string_to_width_with_ellipsis(header_text, viewport_width);
let styled_header = cli_text_inline(&header_text, *header_style).to_string();
queue_commands! {
output_device,
MoveToColumn(0),
ResetColor,
Clear(ClearType::CurrentLine),
Print(styled_header),
MoveToNextLine(1),
ResetColor,
};
Ok(())
}
fn render_multi_line_header(
output_device: &mut OutputDevice,
header_lines: &InlineVec<InlineVec<CliTextInline>>,
viewport_width: ChUnit,
) -> CommonResult<()> {
let mut available_space_col_count: ChUnit = viewport_width - 3;
let mut multi_line_header_clipped_vec =
InlineVec::<InlineVec<CliTextInline>>::with_capacity(header_lines.len());
let mut maybe_clipped_text_vec: InlineVec<InlineVec<InlineString>> =
InlineVec::with_capacity(header_lines.len());
for header_line in header_lines {
let mut header_line_modified = InlineVec::new();
'inner: for span_in_header_line in header_line {
let span_text = &span_in_header_line.text;
let span_text_gcs = GCStringOwned::from(span_text);
let span_us_display_width = *span_text_gcs.display_width;
if span_us_display_width > available_space_col_count {
let clipped_text_str =
span_text_gcs.clip(col(0), width(available_space_col_count));
let clipped_text = inline_string!("{clipped_text_str}...");
header_line_modified.push(clipped_text);
break 'inner;
}
available_space_col_count -= span_us_display_width;
let maybe_header_line_last_span: Option<&CliTextInline> =
header_line.last();
if let Some(header_line_last_span) = maybe_header_line_last_span {
if span_in_header_line == header_line_last_span {
let num_of_spaces: ChUnit = available_space_col_count + ch(3);
let mut span_with_spaces = span_text.to_owned();
span_with_spaces.push_str(&get_spaces(num_of_spaces.as_usize()));
header_line_modified.push(span_with_spaces);
} else {
header_line_modified.push(span_text.to_owned());
}
}
}
available_space_col_count = viewport_width - 3;
maybe_clipped_text_vec.push(header_line_modified);
}
let zipped = maybe_clipped_text_vec.iter().zip(header_lines.iter());
zipped.for_each(|(clipped_text_vec, header_span_vec)| {
let mut ansi_styled_text_vec: InlineVec<CliTextInline> = InlineVec::new();
let zipped = clipped_text_vec.iter().zip(header_span_vec.iter());
zipped.for_each(|(clipped_text, header_span)| {
let style = TuiStyle {
attribs: header_span.attribs,
color_fg: header_span.color_fg,
color_bg: header_span.color_bg,
..Default::default()
};
ansi_styled_text_vec.push(cli_text_inline(clipped_text, 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(ToString::to_string)
.collect::<String>()
})
.collect::<Vec<String>>()
.join("\r\n");
queue_commands! {
output_device,
MoveToColumn(0),
ResetColor,
Clear(ClearType::CurrentLine),
Print(multi_line_header_text),
MoveToNextLine(1),
ResetColor,
};
Ok(())
}
pub fn render_items(
output_device: &mut OutputDevice,
state: &State,
style: &StyleSheet,
render_context: &RenderContext,
) -> CommonResult<()> {
for viewport_row_index in 0..*render_context.items_viewport_height {
let row_context = ItemRowContext::new(
ch(viewport_row_index),
render_context.data_row_index_start,
state,
);
let selection_state = determine_selection_state(&row_context, state);
let data_style = get_style_for_selection_state(selection_state, style);
let row_prefix = create_row_prefix(
&row_context,
state.selection_mode,
render_context.start_display_col_offset,
);
render_single_item(
output_device,
&row_context,
&row_prefix,
&data_style,
render_context.viewport_width,
)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum SelectionStateStyle {
FocusedAndSelected,
Focused,
Selected,
Unselected,
}
#[derive(Debug, Clone, Copy)]
enum Select {
Yes,
No,
}
#[derive(Debug, Clone, Copy)]
enum Focus {
Yes,
No,
}
struct ItemRowContext {
data_item: String,
selected: Select,
focused: Focus,
}
impl ItemRowContext {
fn new(
viewport_row_index: ChUnit,
data_row_index_start: ChUnit,
state: &State,
) -> Self {
let data_row_index: usize =
(data_row_index_start + viewport_row_index).into();
let caret_row_scroll_adj = viewport_row_index + state.scroll_offset_row_index;
let data_item = state.items[data_row_index].to_string();
let selected = if state.selected_items.iter().any(|item| item == &data_item) {
Select::Yes
} else {
Select::No
};
let focused = if caret_row_scroll_adj == state.get_focused_index() {
Focus::Yes
} else {
Focus::No
};
Self {
data_item,
selected,
focused,
}
}
}
fn determine_selection_state(
row_context: &ItemRowContext,
_state: &State,
) -> SelectionStateStyle {
match (row_context.focused, row_context.selected) {
(Focus::Yes, Select::Yes) => SelectionStateStyle::FocusedAndSelected,
(Focus::Yes, Select::No) => SelectionStateStyle::Focused,
(Focus::No, Select::Yes) => SelectionStateStyle::Selected,
(Focus::No, Select::No) => SelectionStateStyle::Unselected,
}
}
fn get_style_for_selection_state(
selection_state: SelectionStateStyle,
style: &StyleSheet,
) -> TuiStyle {
match selection_state {
SelectionStateStyle::FocusedAndSelected => style.focused_and_selected_style,
SelectionStateStyle::Focused => style.focused_style,
SelectionStateStyle::Selected => style.selected_style,
SelectionStateStyle::Unselected => style.unselected_style,
}
}
fn create_row_prefix(
row_context: &ItemRowContext,
selection_mode: HowToChoose,
start_display_col_offset: usize,
) -> String {
let padding_left = get_spaces(start_display_col_offset);
match selection_mode {
HowToChoose::Single => {
if let Focus::Yes = row_context.focused {
format!("{} {SINGLE_SELECT_IS_SELECTED} ", &padding_left)
} else {
format!("{} {SINGLE_SELECT_IS_NOT_SELECTED} ", &padding_left)
}
}
HowToChoose::Multiple => match (row_context.focused, row_context.selected) {
(Focus::Yes, Select::Yes) => {
format!("{} {IS_FOCUSED} {MULTI_SELECT_IS_SELECTED} ", &padding_left)
}
(Focus::Yes, Select::No) => {
format!(
"{} {IS_FOCUSED} {MULTI_SELECT_IS_NOT_SELECTED} ",
&padding_left
)
}
(Focus::No, Select::Yes) => {
format!(
"{} {IS_NOT_FOCUSED} {MULTI_SELECT_IS_SELECTED} ",
&padding_left
)
}
(Focus::No, Select::No) => {
format!(
"{} {IS_NOT_FOCUSED} {MULTI_SELECT_IS_NOT_SELECTED} ",
&padding_left
)
}
},
}
}
fn render_single_item(
output_device: &mut OutputDevice,
row_context: &ItemRowContext,
row_prefix: &str,
data_style: &TuiStyle,
viewport_width: ChUnit,
) -> CommonResult<()> {
let data_item = format!("{row_prefix}{}", row_context.data_item);
let data_item: String =
clip_string_to_width_with_ellipsis(data_item, viewport_width);
let data_item_gcs = GCStringOwned::from(&data_item);
let data_item_display_width: ChUnit = *data_item_gcs.display_width;
let padding_right = if data_item_display_width < viewport_width {
get_spaces(usize(viewport_width - data_item_display_width))
} else {
get_spaces(0)
};
let styled_item = cli_text_inline(&data_item, *data_style).to_string();
let styled_padding = cli_text_inline(&padding_right, *data_style).to_string();
queue_commands! {
output_device,
MoveToColumn(0),
ResetColor,
Clear(ClearType::CurrentLine),
Print(styled_item),
Print(styled_padding),
MoveToNextLine(1),
ResetColor,
};
Ok(())
}
pub fn move_cursor_back_to_start(
output_device: &mut OutputDevice,
items_viewport_height: ChUnit,
header_viewport_height: ChUnit,
) -> CommonResult<()> {
queue_commands! {
output_device,
MoveToPreviousLine(*items_viewport_height + *header_viewport_height),
};
Ok(())
}
}
fn clip_string_to_width_with_ellipsis(
header_text: String,
viewport_width: ChUnit,
) -> String {
let header_text_gcs = GCStringOwned::from(&header_text);
let header_text_display_width = header_text_gcs.display_width;
let available_space_col_count: ChUnit = viewport_width;
if *header_text_display_width > available_space_col_count {
let clipped_text =
header_text_gcs.clip(col(0), width(available_space_col_count - 3));
let clipped_text = format!("{clipped_text}...");
return clipped_text;
}
header_text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ColorSupport, ItemsOwned, OutputDeviceExt,
global_color_support::{clear_override, set_override}};
use pretty_assertions::assert_eq;
use serial_test::serial;
#[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::SingleLine("Header".into()),
items: ItemsOwned::from(&["Item 1", "Item 2", "Item 3"]),
max_display_height: ch(5),
max_display_width: ch(40),
raw_caret_row_index: ch(0),
scroll_offset_row_index: ch(0),
selected_items: ItemsOwned::new(),
selection_mode: HowToChoose::Single,
..Default::default()
};
state.scroll_offset_row_index = ch(0);
let (output_device, stdout_mock) = OutputDevice::new_mock();
let mut component = SelectComponent {
output_device,
style: StyleSheet::default(),
};
set_override(ColorSupport::Ansi256);
component.render(&mut state).unwrap();
let generated_output = stdout_mock.get_copy_of_buffer_as_string();
println!("generated_output = writer.get_buffer(): \n\n{generated_output:#?}\n\n");
let expected_output = "\u{1b}[4F\u{1b}[1G\u{1b}[0m\u{1b}[2K\u{1b}[38:5:153m\u{1b}[48:5:235m Header\u{1b}[0m\u{1b}[1E\u{1b}[0m\u{1b}[1G\u{1b}[0m\u{1b}[2K\u{1b}[38:5:46m ◉ Item 1\u{1b}[0m\u{1b}[38:5:46m \u{1b}[0m\u{1b}[1E\u{1b}[0m\u{1b}[1G\u{1b}[0m\u{1b}[2K ◌ Item 2\u{1b}[0m \u{1b}[0m\u{1b}[1E\u{1b}[0m\u{1b}[1G\u{1b}[0m\u{1b}[2K ◌ Item 3\u{1b}[0m \u{1b}[0m\u{1b}[1E\u{1b}[0m\u{1b}[4F";
assert_eq!(generated_output, expected_output);
clear_override();
}
}