From 8e515d1f046561e48a2359d028d5b520cb5b6476 Mon Sep 17 00:00:00 2001 From: Mikael Mello Date: Thu, 4 Jan 2024 03:23:01 -0800 Subject: [PATCH] Improve incremental renders and handling of terminal window resizing (#211) --- CHANGELOG.md | 1 + inquire/Cargo.toml | 1 + inquire/src/prompts/test.rs | 1 - inquire/src/terminal/console.rs | 12 - inquire/src/terminal/crossterm.rs | 12 - inquire/src/terminal/mod.rs | 3 - inquire/src/terminal/termion.rs | 12 - inquire/src/ui/backend.rs | 254 +++++------------ inquire/src/ui/dimension.rs | 6 +- inquire/src/ui/frame_renderer.rs | 435 ++++++++++++++++++++++++++++++ inquire/src/ui/mod.rs | 1 + inquire/src/ui/style.rs | 34 ++- 12 files changed, 537 insertions(+), 235 deletions(-) create mode 100644 inquire/src/ui/frame_renderer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b04f8c7..0a5c0bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Added 'without_filtering' to both Select and MultiSelect, useful when you want to simplify the UX if the filter does not add any value, such as when the list is already short. - Added 'with_answered_prompt_prefix' to RenderConfig to allow customization of answered prompt prefix. - Revamped keybindings for DateSelect. +- Improved rendering, with optimizations on incremental rendering and terminal resizing. ### Fixes diff --git a/inquire/Cargo.toml b/inquire/Cargo.toml index d5246445..e039b587 100644 --- a/inquire/Cargo.toml +++ b/inquire/Cargo.toml @@ -50,6 +50,7 @@ newline-converter = "0.3" once_cell = "1.18.0" unicode-segmentation = "1" unicode-width = "0.1" +fxhash = "0.2" [dev-dependencies] rstest = "0.18.2" diff --git a/inquire/src/prompts/test.rs b/inquire/src/prompts/test.rs index 17d48eae..0a1050de 100644 --- a/inquire/src/prompts/test.rs +++ b/inquire/src/prompts/test.rs @@ -9,7 +9,6 @@ where { fn read_key(&mut self) -> crate::error::InquireResult { let key = self.next(); - println!("key: {:?}", key); match key { Some(key) => Ok(key), diff --git a/inquire/src/terminal/console.rs b/inquire/src/terminal/console.rs index 07cd902a..7def0731 100644 --- a/inquire/src/terminal/console.rs +++ b/inquire/src/terminal/console.rs @@ -12,7 +12,6 @@ use super::Terminal; #[derive(Clone)] pub struct ConsoleTerminal { term: Term, - in_memory_content: String, } impl ConsoleTerminal { @@ -20,7 +19,6 @@ impl ConsoleTerminal { pub fn new() -> Self { Self { term: Term::stderr(), - in_memory_content: String::new(), } } } @@ -80,12 +78,10 @@ impl Terminal for ConsoleTerminal { } fn write(&mut self, val: T) -> Result<()> { - self.in_memory_content.push_str(&val.to_string()); write!(self.term, "{}", val) } fn write_styled(&mut self, val: &Styled) -> Result<()> { - self.in_memory_content.push_str(&val.content.to_string()); let styled_object = Style::from(val.style).apply_to(&val.content); write!(self.term, "{}", styled_object) } @@ -105,14 +101,6 @@ impl Terminal for ConsoleTerminal { fn cursor_show(&mut self) -> Result<()> { self.term.show_cursor() } - - fn get_in_memory_content(&self) -> &str { - &self.in_memory_content - } - - fn clear_in_memory_content(&mut self) { - self.in_memory_content.clear(); - } } impl Drop for ConsoleTerminal { diff --git a/inquire/src/terminal/crossterm.rs b/inquire/src/terminal/crossterm.rs index c6221398..3d0a9cdc 100644 --- a/inquire/src/terminal/crossterm.rs +++ b/inquire/src/terminal/crossterm.rs @@ -24,7 +24,6 @@ enum IO { pub struct CrosstermTerminal { io: IO, - in_memory_content: String, } pub struct CrosstermKeyReader; @@ -51,7 +50,6 @@ impl CrosstermTerminal { Ok(Self { io: IO::Std(stderr()), - in_memory_content: String::new(), }) } @@ -140,7 +138,6 @@ impl Terminal for CrosstermTerminal { } fn write(&mut self, val: T) -> Result<()> { - self.in_memory_content.push_str(&val.to_string()); self.write_command(Print(val)) } @@ -185,14 +182,6 @@ impl Terminal for CrosstermTerminal { fn cursor_show(&mut self) -> Result<()> { self.write_command(cursor::Show) } - - fn get_in_memory_content(&self) -> &str { - &self.in_memory_content - } - - fn clear_in_memory_content(&mut self) { - self.in_memory_content.clear(); - } } impl Drop for CrosstermTerminal { @@ -345,7 +334,6 @@ mod test { pub fn new_in_memory_output() -> Self { Self { io: IO::Test(Vec::new()), - in_memory_content: String::new(), } } diff --git a/inquire/src/terminal/mod.rs b/inquire/src/terminal/mod.rs index b5ec6f5b..0e15d00b 100644 --- a/inquire/src/terminal/mod.rs +++ b/inquire/src/terminal/mod.rs @@ -28,9 +28,6 @@ pub trait Terminal: Sized { fn clear_line(&mut self) -> Result<()>; fn clear_until_new_line(&mut self) -> Result<()>; - fn get_in_memory_content(&self) -> &str; - fn clear_in_memory_content(&mut self); - fn cursor_hide(&mut self) -> Result<()>; fn cursor_show(&mut self) -> Result<()>; fn cursor_up(&mut self, cnt: u16) -> Result<()>; diff --git a/inquire/src/terminal/termion.rs b/inquire/src/terminal/termion.rs index 699ab35f..6fcd6464 100644 --- a/inquire/src/terminal/termion.rs +++ b/inquire/src/terminal/termion.rs @@ -53,7 +53,6 @@ impl InputReader for TermionKeyReader { pub struct TermionTerminal<'a> { io: IO<'a>, - in_memory_content: String, } impl<'a> TermionTerminal<'a> { @@ -65,7 +64,6 @@ impl<'a> TermionTerminal<'a> { Ok(Self { io: IO::TTY(raw_terminal), - in_memory_content: String::new(), }) } @@ -76,7 +74,6 @@ impl<'a> TermionTerminal<'a> { pub fn new_with_writer(writer: &'a mut W) -> Self { Self { io: IO::Custom(writer), - in_memory_content: String::new(), } } @@ -161,7 +158,6 @@ impl<'a> Terminal for TermionTerminal<'a> { } fn write(&mut self, val: T) -> Result<()> { - self.in_memory_content.push_str(&val.to_string()); write!(self.get_writer(), "{}", val) } @@ -206,14 +202,6 @@ impl<'a> Terminal for TermionTerminal<'a> { fn cursor_show(&mut self) -> Result<()> { write!(self.get_writer(), "{}", termion::cursor::Show) } - - fn get_in_memory_content(&self) -> &str { - self.in_memory_content.as_ref() - } - - fn clear_in_memory_content(&mut self) { - self.in_memory_content.clear(); - } } impl<'a> Drop for TermionTerminal<'a> { diff --git a/inquire/src/ui/backend.rs b/inquire/src/ui/backend.rs index c9baef52..47f8a618 100644 --- a/inquire/src/ui/backend.rs +++ b/inquire/src/ui/backend.rs @@ -1,19 +1,16 @@ -use crate::ansi::AnsiStrippable; use std::{collections::BTreeSet, fmt::Display, io::Result}; -use unicode_width::UnicodeWidthChar; - use crate::{ error::InquireResult, input::Input, list_option::ListOption, - terminal::{Terminal, TerminalSize}, + terminal::Terminal, ui::{IndexPrefix, Key, RenderConfig, Styled}, utils::{int_log10, Page}, validator::ErrorMessage, }; -use super::InputReader; +use super::{frame_renderer::FrameRenderer, InputReader}; pub trait CommonBackend: InputReader { fn frame_setup(&mut self) -> Result<()>; @@ -81,14 +78,8 @@ where I: InputReader, T: Terminal, { - prompt_current_position: Position, - prompt_end_position: Position, - prompt_cursor_offset: Option, - prompt_cursor_position: Option, - show_cursor: bool, + frame_renderer: FrameRenderer, input_reader: I, - terminal: T, - terminal_size: TerminalSize, render_config: RenderConfig<'a>, } @@ -99,113 +90,15 @@ where { #[allow(clippy::large_types_passed_by_value)] pub fn new(input_reader: I, terminal: T, render_config: RenderConfig<'a>) -> Result { - let terminal_size = terminal.get_size().unwrap_or(TerminalSize::new(1000, 1000)); - - let mut backend = Self { - prompt_current_position: Position::default(), - prompt_end_position: Position::default(), - prompt_cursor_offset: None, - prompt_cursor_position: None, - show_cursor: false, - terminal, + let backend = Self { + frame_renderer: FrameRenderer::new(terminal)?, input_reader, render_config, - terminal_size, }; - backend.terminal.cursor_hide()?; - Ok(backend) } - fn update_position_info(&mut self) { - let input = self.terminal.get_in_memory_content(); - let term_width = self.terminal_size.width(); - - let mut cur_pos = Position::default(); - - for (idx, c) in input.ansi_stripped_chars().enumerate() { - let len = UnicodeWidthChar::width(c).unwrap_or(0) as u16; - - if c == '\n' { - cur_pos.row = cur_pos.row.saturating_add(1); - cur_pos.col = 0; - } else { - let left = term_width - cur_pos.col; - - if left >= len { - cur_pos.col = cur_pos.col.saturating_add(len); - } else { - cur_pos.row = cur_pos.row.saturating_add(1); - cur_pos.col = len; - } - } - - if let Some(prompt_cursor_offset) = self.prompt_cursor_offset { - if prompt_cursor_offset == idx { - let mut cursor_position = cur_pos; - cursor_position.col = cursor_position.col.saturating_sub(len); - self.prompt_cursor_position = Some(cursor_position); - } - } - } - - self.prompt_current_position = cur_pos; - self.prompt_end_position = cur_pos; - } - - fn move_cursor_to_end_position(&mut self) -> Result<()> { - if self.prompt_current_position.row != self.prompt_end_position.row { - let diff = self - .prompt_end_position - .row - .saturating_sub(self.prompt_current_position.row); - self.terminal.cursor_down(diff)?; - self.terminal - .cursor_move_to_column(self.prompt_end_position.col)?; - } - - Ok(()) - } - - fn update_cursor_status(&mut self) -> Result<()> { - match self.show_cursor { - true => self.terminal.cursor_show(), - false => self.terminal.cursor_hide(), - } - } - - fn mark_prompt_cursor_position(&mut self, offset: usize) { - let current = self.terminal.get_in_memory_content(); - let position = current.chars().count(); - let position = position.saturating_add(offset); - - self.prompt_cursor_offset = Some(position); - } - - fn reset_prompt(&mut self) -> Result<()> { - self.move_cursor_to_end_position()?; - - for _ in 0..self.prompt_end_position.row { - self.terminal.cursor_up(1)?; - self.terminal.clear_line()?; - } - - self.terminal.clear_in_memory_content(); - - self.prompt_current_position = Position::default(); - self.prompt_end_position = Position::default(); - self.prompt_cursor_position = None; - self.prompt_cursor_offset = None; - - // let's default to false to catch any previous - // default behaviors we didn't account for - self.show_cursor = false; - self.terminal.cursor_hide()?; - - Ok(()) - } - fn print_option_prefix( &mut self, option_relative_index: usize, @@ -223,7 +116,7 @@ where empty_prefix }; - self.terminal.write_styled(&x) + self.frame_renderer.write_styled(x) } fn print_option_value( @@ -241,8 +134,8 @@ where self.render_config.option }; - self.terminal - .write_styled(&Styled::new(&option.value).with_style_sheet(stylesheet)) + self.frame_renderer + .write_styled(Styled::new(&option.value).with_style_sheet(stylesheet)) } fn print_option_index_prefix(&mut self, index: usize, max_index: usize) -> Option> { @@ -262,8 +155,8 @@ where }; content.map(|prefix| { - self.terminal - .write_styled(&Styled::new(prefix).with_style_sheet(self.render_config.option)) + self.frame_renderer + .write_styled(Styled::new(prefix).with_style_sheet(self.render_config.option)) }) } @@ -271,16 +164,16 @@ where let content = format!("({value})"); let token = Styled::new(content).with_style_sheet(self.render_config.default_value); - self.terminal.write_styled(&token) + self.frame_renderer.write_styled(token) } fn print_prompt_with_prefix(&mut self, prefix: Styled<&str>, prompt: &str) -> Result<()> { - self.terminal.write_styled(&prefix)?; + self.frame_renderer.write_styled(prefix)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; - self.terminal - .write_styled(&Styled::new(prompt).with_style_sheet(self.render_config.prompt))?; + self.frame_renderer + .write_styled(Styled::new(prompt).with_style_sheet(self.render_config.prompt))?; Ok(()) } @@ -290,23 +183,23 @@ where } fn print_input(&mut self, input: &Input) -> Result<()> { - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; let cursor_offset = input.pre_cursor().chars().count(); - self.mark_prompt_cursor_position(cursor_offset); - self.show_cursor = true; + self.frame_renderer + .mark_cursor_position(cursor_offset as isize); if input.is_empty() { match input.placeholder() { None => {} Some(p) if p.is_empty() => {} - Some(p) => self.terminal.write_styled( - &Styled::new(p).with_style_sheet(self.render_config.placeholder), + Some(p) => self.frame_renderer.write_styled( + Styled::new(p).with_style_sheet(self.render_config.placeholder), )?, } } else { - self.terminal.write_styled( - &Styled::new(input.content()).with_style_sheet(self.render_config.text_input), + self.frame_renderer.write_styled( + Styled::new(input.content()).with_style_sheet(self.render_config.text_input), )?; } @@ -314,7 +207,7 @@ where // a space, otherwise the cursor will render on the // \n character, on the next line. if input.cursor() == input.length() { - self.terminal.write(' ')?; + self.frame_renderer.write(' ')?; } Ok(()) @@ -329,7 +222,7 @@ where self.print_prompt(prompt)?; if let Some(default) = default { - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; self.print_default_value(default)?; } @@ -340,14 +233,8 @@ where Ok(()) } - fn flush(&mut self) -> Result<()> { - self.terminal.flush()?; - - Ok(()) - } - fn new_line(&mut self) -> Result<()> { - self.terminal.write("\r\n")?; + self.frame_renderer.write("\n")?; Ok(()) } } @@ -358,37 +245,20 @@ where T: Terminal, { fn frame_setup(&mut self) -> Result<()> { - self.terminal.cursor_hide()?; - self.terminal.flush()?; - - self.reset_prompt() + self.frame_renderer.start_frame() } fn frame_finish(&mut self) -> Result<()> { - self.update_position_info(); - - if let Some(prompt_cursor_position) = self.prompt_cursor_position { - let row_diff = self.prompt_current_position.row - prompt_cursor_position.row; - - self.terminal.cursor_up(row_diff)?; - self.terminal - .cursor_move_to_column(prompt_cursor_position.col)?; - - self.prompt_current_position = prompt_cursor_position; - } - - self.update_cursor_status()?; - - self.flush() + self.frame_renderer.finish_current_frame() } fn render_canceled_prompt(&mut self, prompt: &str) -> Result<()> { self.print_prompt(prompt)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; - self.terminal - .write_styled(&self.render_config.canceled_prompt_indicator)?; + self.frame_renderer + .write_styled(self.render_config.canceled_prompt_indicator)?; self.new_line()?; @@ -398,10 +268,10 @@ where fn render_prompt_with_answer(&mut self, prompt: &str, answer: &str) -> Result<()> { self.print_prompt_with_prefix(self.render_config.answered_prompt_prefix, prompt)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; let token = Styled::new(answer).with_style_sheet(self.render_config.answer); - self.terminal.write_styled(&token)?; + self.frame_renderer.write_styled(token)?; self.new_line()?; @@ -409,11 +279,11 @@ where } fn render_error_message(&mut self, error: &ErrorMessage) -> Result<()> { - self.terminal - .write_styled(&self.render_config.error_message.prefix)?; + self.frame_renderer + .write_styled(self.render_config.error_message.prefix)?; - self.terminal.write_styled( - &Styled::new(" ").with_style_sheet(self.render_config.error_message.separator), + self.frame_renderer.write_styled( + Styled::new(" ").with_style_sheet(self.render_config.error_message.separator), )?; let message = match error { @@ -421,8 +291,8 @@ where ErrorMessage::Custom(msg) => msg, }; - self.terminal.write_styled( - &Styled::new(message).with_style_sheet(self.render_config.error_message.message), + self.frame_renderer.write_styled( + Styled::new(message).with_style_sheet(self.render_config.error_message.message), )?; self.new_line()?; @@ -431,14 +301,14 @@ where } fn render_help_message(&mut self, help: &str) -> Result<()> { - self.terminal - .write_styled(&Styled::new("[").with_style_sheet(self.render_config.help_message))?; + self.frame_renderer + .write_styled(Styled::new("[").with_style_sheet(self.render_config.help_message))?; - self.terminal - .write_styled(&Styled::new(help).with_style_sheet(self.render_config.help_message))?; + self.frame_renderer + .write_styled(Styled::new(help).with_style_sheet(self.render_config.help_message))?; - self.terminal - .write_styled(&Styled::new("]").with_style_sheet(self.render_config.help_message))?; + self.frame_renderer + .write_styled(Styled::new("]").with_style_sheet(self.render_config.help_message))?; self.new_line()?; @@ -464,7 +334,7 @@ where for (idx, option) in page.content.iter().enumerate() { self.print_option_prefix(idx, &page)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; self.print_option_value(idx, option, &page)?; self.new_line()?; @@ -483,11 +353,11 @@ where fn render_prompt(&mut self, prompt: &str, editor_command: &str) -> Result<()> { self.print_prompt(prompt)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; let message = format!("[(e) to open {}, (enter) to submit]", editor_command); let token = Styled::new(message).with_style_sheet(self.render_config.editor_prompt); - self.terminal.write_styled(&token)?; + self.frame_renderer.write_styled(token)?; self.new_line()?; @@ -512,11 +382,11 @@ where for (idx, option) in page.content.iter().enumerate() { self.print_option_prefix(idx, &page)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; if let Some(res) = self.print_option_index_prefix(option.index, page.total) { res?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; } self.print_option_value(idx, option, &page)?; @@ -549,11 +419,11 @@ where for (idx, option) in page.content.iter().enumerate() { self.print_option_prefix(idx, &page)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; if let Some(res) = self.print_option_index_prefix(option.index, page.total) { res?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; } let mut checkbox = match checked.contains(&option.index) { @@ -566,9 +436,9 @@ where _ => {} } - self.terminal.write_styled(&checkbox)?; + self.frame_renderer.write_styled(checkbox)?; - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; self.print_option_value(idx, option, &page)?; @@ -632,9 +502,9 @@ pub mod date { ) -> Result<()> { macro_rules! write_prefix { () => {{ - self.terminal - .write_styled(&self.render_config.calendar.prefix)?; - self.terminal.write(" ") + self.frame_renderer + .write_styled(self.render_config.calendar.prefix)?; + self.frame_renderer.write(" ") }}; } @@ -645,7 +515,7 @@ pub mod date { write_prefix!()?; - self.terminal.write_styled(&header)?; + self.frame_renderer.write_styled(header)?; self.new_line()?; @@ -666,7 +536,7 @@ pub mod date { write_prefix!()?; - self.terminal.write_styled(&week_days)?; + self.frame_renderer.write_styled(week_days)?; self.new_line()?; // print dates @@ -688,7 +558,7 @@ pub mod date { for i in 0..7 { if i > 0 { - self.terminal.write(" ")?; + self.frame_renderer.write(" ")?; } let date = format!("{:2}", date_it.day()); @@ -698,12 +568,10 @@ pub mod date { let mut style_sheet = crate::ui::StyleSheet::empty(); if date_it == selected_date { - self.mark_prompt_cursor_position(cursor_offset); + self.frame_renderer.mark_cursor_position(cursor_offset); if let Some(custom_style_sheet) = self.render_config.calendar.selected_date { style_sheet = custom_style_sheet; - } else { - self.show_cursor = true; } } else if date_it == today { style_sheet = self.render_config.calendar.today_date; @@ -724,7 +592,7 @@ pub mod date { } let token = Styled::new(date).with_style_sheet(style_sheet); - self.terminal.write_styled(&token)?; + self.frame_renderer.write_styled(token)?; date_it = date_it.succ_opt().unwrap_or(date_it); } diff --git a/inquire/src/ui/dimension.rs b/inquire/src/ui/dimension.rs index a709c0a7..58b586ce 100644 --- a/inquire/src/ui/dimension.rs +++ b/inquire/src/ui/dimension.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct Dimension { width: u16, height: u16, @@ -12,4 +12,8 @@ impl Dimension { pub fn width(&self) -> u16 { self.width } + + pub fn height(&self) -> u16 { + self.height + } } diff --git a/inquire/src/ui/frame_renderer.rs b/inquire/src/ui/frame_renderer.rs new file mode 100644 index 00000000..073262fc --- /dev/null +++ b/inquire/src/ui/frame_renderer.rs @@ -0,0 +1,435 @@ +use std::cmp::Ordering; +use std::fmt::Display; +use std::hash::{Hash, Hasher}; +use std::io; + +use fxhash::FxHasher; +use unicode_width::UnicodeWidthChar; + +use super::dimension::Dimension; +use super::{Position, Styled}; +use crate::ansi::{AnsiAware, AnsiAwareChar}; +use crate::terminal::{Terminal, TerminalSize}; + +#[derive(Debug, Default)] +struct FrameRow { + content: Vec>, + hash: u64, +} + +impl FrameRow { + pub fn new(content: Vec>, hash: u64) -> Self { + Self { content, hash } + } + + pub fn get_content(&self) -> &[Styled] { + &self.content + } + + pub fn hash(&self) -> u64 { + self.hash + } +} + +#[derive(Debug)] +struct FrameState { + /// terminal size when the frame was rendered + pub terminal_size: TerminalSize, + /// resulting frame size + pub frame_size: Dimension, + /// position to put cursor after writing all present content + pub expected_cursor_position: Option, + /// content and pre-calculated hashes for each rendered line + /// the length of this vector should be equal to frame_size.height + pub finished_rows: Vec, + pub current_styled: Styled, + pub current_line: Vec>, + pub current_line_width: u16, + pub current_line_hasher: FxHasher, +} + +impl FrameState { + pub fn new(terminal_size: TerminalSize) -> Self { + Self { + terminal_size, + frame_size: Dimension::new(0, 0), + finished_rows: Vec::new(), + current_styled: Styled::default(), + current_line: Vec::new(), + current_line_hasher: FxHasher::default(), + current_line_width: 0, + expected_cursor_position: None, + } + } + + pub fn write(&mut self, value: &Styled + Display>) { + self.current_styled.style = value.style; + + for piece in value.content.ansi_aware_chars() { + piece.hash(&mut self.current_line_hasher); + value.style.hash(&mut self.current_line_hasher); + + let current_char = match piece { + AnsiAwareChar::Char(c) => c, + AnsiAwareChar::AnsiEscapeSequence(_) => { + // we don't care for escape sequences when calculating cursor position + // and box size + continue; + } + }; + + if current_char == '\n' { + self.finish_line(); + continue; + } + + let remaining_width_space = self.terminal_size.width() - self.current_line_width; + let character_length = UnicodeWidthChar::width(current_char).unwrap_or(0) as u16; + + if character_length > remaining_width_space { + // the character will (probably) not fit into the current line + self.finish_line(); + } + + self.current_line_width = self.current_line_width.saturating_add(character_length); + self.current_styled.content.push(current_char); + } + + if !self.current_styled.content.is_empty() { + self.current_line + .push(std::mem::take(&mut self.current_styled)); + } + } + + pub fn mark_cursor_position(&mut self, offset: isize) { + let mut row = self.finished_rows.len() as u16; + let mut col = self.current_line_width; + + col = col.saturating_add(offset as u16); + + if col >= self.terminal_size.width() { + col -= self.terminal_size.width(); + row += 1; + } + + self.expected_cursor_position = Some(Position { row, col }); + } + + pub fn finish(&mut self) { + self.finish_line(); + } + + pub fn resize_if_needed(&mut self, new_size: TerminalSize) { + if new_size == self.terminal_size { + return; + } + + let mut new_state = Self::new(new_size); + for row in &self.finished_rows { + for styled in row.get_content() { + new_state.write(styled); + } + new_state.finish_line(); + } + for styled in &self.current_line { + new_state.write(styled); + } + new_state.finish_line(); + + *self = new_state; + } + + fn finish_line(&mut self) { + let current_styled = std::mem::take(&mut self.current_styled); + self.current_styled.style = current_styled.style; + + if !current_styled.content.is_empty() || !current_styled.style.is_empty() { + self.current_line.push(current_styled); + } + + let hasher = std::mem::take(&mut self.current_line_hasher); + let content = std::mem::take(&mut self.current_line); + + if content.is_empty() { + return; + } + + self.finished_rows + .push(FrameRow::new(content, hasher.finish())); + + self.frame_size = Dimension::new( + self.frame_size.width().max(self.current_line_width), + self.finished_rows.len() as u16, + ); + + if !self.current_styled.style.is_empty() { + self.current_styled + .style + .hash(&mut self.current_line_hasher); + } + + self.current_line_width = 0; + } +} + +#[derive(Debug, Default)] +enum RenderState { + #[default] + Initial, + ActiveRender { + last_rendered_frame: FrameState, + current_frame: FrameState, + }, + Rendered(FrameState), +} + +pub struct FrameRenderer +where + T: Terminal, +{ + terminal: T, + cursor_position: Position, + state: RenderState, +} + +impl FrameRenderer +where + T: Terminal, +{ + pub fn new(terminal: T) -> io::Result { + Ok(Self { + terminal, + cursor_position: Position::default(), + state: RenderState::Initial, + }) + } + + pub fn write(&mut self, value: impl Display) -> io::Result<()> { + self.write_styled(Styled::new(value)) + } + + pub fn write_styled(&mut self, value: Styled) -> io::Result<()> { + match &mut self.state { + RenderState::Rendered(_) | RenderState::Initial => {} + RenderState::ActiveRender { current_frame, .. } => { + // here we are converting from a generic impl Display to String + // because we are storing the string content in the frame (we can't store a ref to an object, for example). + // + // we pay a little bit in memory/cpu usage for this so we can + // calculate incremental rendering and cursor position on-the-fly. + let formatted = format!("{}", value.content); + let value = value.with_content(formatted); + + current_frame.write(&value); + } + } + + Ok(()) + } + + pub fn mark_cursor_position(&mut self, offset: isize) { + match &mut self.state { + RenderState::Rendered(_) | RenderState::Initial => {} + RenderState::ActiveRender { current_frame, .. } => { + current_frame.mark_cursor_position(offset); + } + } + } + + pub fn start_frame(&mut self) -> io::Result<()> { + let terminal_size = self.refresh_terminal_size(); + + self.state = match std::mem::replace(&mut self.state, RenderState::Initial) { + RenderState::Initial => RenderState::ActiveRender { + last_rendered_frame: FrameState::new(terminal_size), + current_frame: FrameState::new(terminal_size), + }, + + RenderState::Rendered(last_rendered_frame) => RenderState::ActiveRender { + last_rendered_frame, + current_frame: FrameState::new(terminal_size), + }, + + RenderState::ActiveRender { + last_rendered_frame, + current_frame, + } => RenderState::ActiveRender { + last_rendered_frame, + current_frame, + }, + }; + + Ok(()) + } + + pub fn finish_current_frame(&mut self) -> io::Result<()> { + let (last_rendered_frame, mut current_frame) = match std::mem::take(&mut self.state) { + RenderState::Rendered(_) | RenderState::Initial => { + return Ok(()); + } + RenderState::ActiveRender { + last_rendered_frame, + current_frame, + } => (last_rendered_frame, current_frame), + }; + + current_frame.finish(); + + let rows_to_iterate = std::cmp::max( + last_rendered_frame.frame_size.height(), + current_frame.frame_size.height(), + ); + + self.terminal.cursor_hide()?; + self.move_cursor_to(Position { row: 0, col: 0 })?; + + for i in 0..rows_to_iterate { + let last_row = last_rendered_frame.finished_rows.get(i as usize); + let current_row = current_frame.finished_rows.get(i as usize); + + match (last_row, current_row) { + (Some(last_row), Some(current_row)) => { + if last_row.hash() != current_row.hash() { + for styled in current_row.get_content() { + self.terminal.write_styled(styled)?; + } + self.terminal.clear_until_new_line()?; + } + } + (Some(_), None) => { + self.terminal.clear_line()?; + } + (None, Some(current_row)) => { + for styled in current_row.get_content() { + self.terminal.write_styled(styled)?; + } + } + (None, None) => { + // unreachable, but we don't want to panic live :) + #[cfg(test)] + unreachable!( + "frame_size should never be larger then finished_rows for both frames" + ) + } + } + + self.terminal.write("\r")?; + self.cursor_position.col = 0; + if i + 1 < rows_to_iterate { + self.terminal.write("\n")?; + self.cursor_position.row += 1; + } + } + + if let Some(expected_cursor_position) = current_frame.expected_cursor_position { + self.move_cursor_to(expected_cursor_position)?; + } + + self.terminal.cursor_show()?; + self.terminal.flush()?; + + self.state = RenderState::Rendered(current_frame); + + Ok(()) + } + + fn move_cursor_to_end_position(&mut self) -> io::Result<()> { + self.refresh_terminal_size(); + + let last_rendered = match &mut self.state { + RenderState::Initial => return Ok(()), + RenderState::ActiveRender { + last_rendered_frame, + .. + } + | RenderState::Rendered(last_rendered_frame) => last_rendered_frame, + }; + + let end_position = Position { + col: 0, + row: last_rendered.frame_size.height(), + }; + + self.move_cursor_to(end_position)?; + + Ok(()) + } + + fn move_cursor_to(&mut self, position: Position) -> io::Result<()> { + let current_cursor_position = self.cursor_position; + + match current_cursor_position.row.cmp(&position.row) { + Ordering::Greater => { + self.terminal + .cursor_up(current_cursor_position.row - position.row)?; + } + Ordering::Less => { + self.terminal + .cursor_down(position.row - current_cursor_position.row)?; + } + Ordering::Equal => {} + } + + match current_cursor_position.col.cmp(&position.col) { + Ordering::Greater => { + self.terminal + .cursor_left(current_cursor_position.col - position.col)?; + } + Ordering::Less => { + self.terminal + .cursor_right(position.col - current_cursor_position.col)?; + } + Ordering::Equal => {} + } + + self.cursor_position = position; + + Ok(()) + } + + fn refresh_terminal_size(&mut self) -> TerminalSize { + // not properly handling resizes is better than panicking, so when + // getting the terminal size fails, we assume we're on a terminal + // that will always have enough space + let terminal_size = self + .terminal + .get_size() + .unwrap_or(TerminalSize::new(1000, 1000)); + + if terminal_size.width() < self.cursor_position.col { + let new_line_offset = self.cursor_position.col / terminal_size.width(); + let new_col = self.cursor_position.col % terminal_size.width(); + self.cursor_position = Position { + row: self.cursor_position.row + new_line_offset, + col: new_col, + }; + } + + match &mut self.state { + RenderState::Initial => {} + RenderState::ActiveRender { + current_frame, + last_rendered_frame, + } => { + last_rendered_frame.resize_if_needed(terminal_size); + current_frame.resize_if_needed(terminal_size); + } + RenderState::Rendered(last_rendered_frame) => { + last_rendered_frame.resize_if_needed(terminal_size); + } + }; + + terminal_size + } +} + +impl Drop for FrameRenderer +where + T: Terminal, +{ + fn drop(&mut self) { + let _unused = self.move_cursor_to_end_position(); + let _unused = self.terminal.cursor_show(); + let _unused = self.terminal.flush(); + } +} diff --git a/inquire/src/ui/mod.rs b/inquire/src/ui/mod.rs index c17d38fe..999ffa94 100644 --- a/inquire/src/ui/mod.rs +++ b/inquire/src/ui/mod.rs @@ -3,6 +3,7 @@ mod backend; mod color; pub(crate) mod dimension; +mod frame_renderer; mod input_reader; mod key; mod render_config; diff --git a/inquire/src/ui/style.rs b/inquire/src/ui/style.rs index 28d50ebb..a9de81c3 100644 --- a/inquire/src/ui/style.rs +++ b/inquire/src/ui/style.rs @@ -48,7 +48,7 @@ bitflags! { /// /// assert!(!style_sheet.is_empty()); /// ``` -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct StyleSheet { /// Foreground color of text. pub fg: Option, @@ -181,6 +181,38 @@ where self.style.att = attributes; self } + + /// Updates the content while keeping the style sheet constant. + pub fn with_content(self, content: U) -> Styled + where + U: Display, + { + Styled { + content, + style: self.style, + } + } } impl Copy for Styled where T: Copy + Display {} + +impl Default for Styled +where + T: Default + Display, +{ + fn default() -> Self { + Self { + content: Default::default(), + style: Default::default(), + } + } +} + +impl From for Styled +where + T: Display, +{ + fn from(from: T) -> Self { + Self::new(from) + } +}