diff --git a/src/list/state.rs b/src/list/state.rs index cc56346..3876884 100644 --- a/src/list/state.rs +++ b/src/list/state.rs @@ -13,7 +13,7 @@ use std::{ use crate::{ app_state::AppState, exercise::Exercise, - term::{progress_bar, terminal_file_link}, + term::{progress_bar, terminal_file_link, CountedWrite, MaxLenWriter}, MAX_EXERCISE_NAME_LEN, }; @@ -28,14 +28,6 @@ fn next_ln(stdout: &mut StdoutLock) -> io::Result<()> { Ok(()) } -// Avoids having the last written char as the last displayed one when the -// written width is higher than the terminal width. -// Happens on the Gnome terminal for example. -fn next_ln_overwrite(stdout: &mut StdoutLock) -> io::Result<()> { - stdout.write_all(b" ")?; - next_ln(stdout) -} - #[derive(Copy, Clone, PartialEq, Eq)] pub enum Filter { Done, @@ -164,40 +156,44 @@ impl<'a> ListState<'a> { .skip(self.row_offset) .take(self.max_n_rows_to_display) { + let mut writer = MaxLenWriter::new(stdout, self.term_width as usize); + if self.selected_row == Some(self.row_offset + n_displayed_rows) { - stdout.queue(SetBackgroundColor(Color::Rgb { + writer.stdout.queue(SetBackgroundColor(Color::Rgb { r: 40, g: 40, b: 40, }))?; - stdout.write_all("🦀".as_bytes())?; + // The crab emoji has the width of two ascii chars. + writer.add_to_len(2); + writer.stdout.write_all("🦀".as_bytes())?; } else { - stdout.write_all(b" ")?; + writer.write_ascii(b" ")?; } if exercise_ind == current_exercise_ind { - stdout.queue(SetForegroundColor(Color::Red))?; - stdout.write_all(b">>>>>>> ")?; + writer.stdout.queue(SetForegroundColor(Color::Red))?; + writer.write_ascii(b">>>>>>> ")?; } else { - stdout.write_all(b" ")?; + writer.write_ascii(b" ")?; } if exercise.done { - stdout.queue(SetForegroundColor(Color::Green))?; - stdout.write_all(b"DONE ")?; + writer.stdout.queue(SetForegroundColor(Color::Green))?; + writer.write_ascii(b"DONE ")?; } else { - stdout.queue(SetForegroundColor(Color::Yellow))?; - stdout.write_all(b"PENDING ")?; + writer.stdout.queue(SetForegroundColor(Color::Yellow))?; + writer.write_ascii(b"PENDING ")?; } - stdout.queue(SetForegroundColor(Color::Reset))?; + writer.stdout.queue(SetForegroundColor(Color::Reset))?; - stdout.write_all(exercise.name.as_bytes())?; - stdout.write_all(&SPACE[..self.name_col_width + 2 - exercise.name.len()])?; + writer.write_str(exercise.name)?; + writer.write_ascii(&SPACE[..self.name_col_width + 2 - exercise.name.len()])?; - terminal_file_link(stdout, exercise.path, Color::Blue)?; + terminal_file_link(&mut writer, exercise.path, Color::Blue)?; - next_ln_overwrite(stdout)?; + next_ln(stdout)?; stdout.queue(ResetColor)?; n_displayed_rows += 1; } @@ -213,10 +209,11 @@ impl<'a> ListState<'a> { stdout.queue(BeginSynchronizedUpdate)?.queue(MoveTo(0, 0))?; // Header - stdout.write_all(b" Current State Name")?; - stdout.write_all(&SPACE[..self.name_col_width - 2])?; - stdout.write_all(b"Path")?; - next_ln_overwrite(stdout)?; + let mut writer = MaxLenWriter::new(stdout, self.term_width as usize); + writer.write_ascii(b" Current State Name")?; + writer.write_ascii(&SPACE[..self.name_col_width - 2])?; + writer.write_ascii(b"Path")?; + next_ln(stdout)?; // Rows let iter = self.app_state.exercises().iter().enumerate(); @@ -237,7 +234,7 @@ impl<'a> ListState<'a> { next_ln(stdout)?; progress_bar( - stdout, + &mut MaxLenWriter::new(stdout, self.term_width as usize), self.app_state.n_done(), self.app_state.exercises().len() as u16, self.term_width, @@ -247,59 +244,55 @@ impl<'a> ListState<'a> { stdout.write_all(&self.separator_line)?; next_ln(stdout)?; + let mut writer = MaxLenWriter::new(stdout, self.term_width as usize); if self.message.is_empty() { // Help footer message if self.selected_row.is_some() { - stdout.write_all( - "↓/j ↑/k home/g end/G | ontinue at | eset exercise".as_bytes(), - )?; + writer.write_str("↓/j ↑/k home/g end/G | ontinue at | eset exercise")?; if self.narrow_term { - next_ln_overwrite(stdout)?; - stdout.write_all(b"filter ")?; + next_ln(stdout)?; + writer = MaxLenWriter::new(stdout, self.term_width as usize); + + writer.write_ascii(b"filter ")?; } else { - stdout.write_all(b" | filter ")?; + writer.write_ascii(b" | filter ")?; } } else { // Nothing selected (and nothing shown), so only display filter and quit. - stdout.write_all(b"filter ")?; + writer.write_ascii(b"filter ")?; } match self.filter { Filter::Done => { - stdout + writer + .stdout .queue(SetForegroundColor(Color::Magenta))? .queue(SetAttribute(Attribute::Underlined))?; - stdout.write_all(b"one")?; - stdout.queue(ResetColor)?; - stdout.write_all(b"/

ending")?; + writer.write_ascii(b"one")?; + writer.stdout.queue(ResetColor)?; + writer.write_ascii(b"/

ending")?; } Filter::Pending => { - stdout.write_all(b"one/")?; - stdout + writer.write_ascii(b"one/")?; + writer + .stdout .queue(SetForegroundColor(Color::Magenta))? .queue(SetAttribute(Attribute::Underlined))?; - stdout.write_all(b"

ending")?; - stdout.queue(ResetColor)?; + writer.write_ascii(b"

ending")?; + writer.stdout.queue(ResetColor)?; } - Filter::None => stdout.write_all(b"one/

ending")?, + Filter::None => writer.write_ascii(b"one/

ending")?, } - stdout.write_all(b" | uit list")?; - - if self.narrow_term { - next_ln_overwrite(stdout)?; - } else { - next_ln(stdout)?; - } + writer.write_ascii(b" | uit list")?; } else { - stdout.queue(SetForegroundColor(Color::Magenta))?; - stdout.write_all(self.message.as_bytes())?; + writer.stdout.queue(SetForegroundColor(Color::Magenta))?; + writer.write_str(&self.message)?; stdout.queue(ResetColor)?; - next_ln_overwrite(stdout)?; - if self.narrow_term { - next_ln(stdout)?; - } + next_ln(stdout)?; } + + next_ln(stdout)?; } stdout.queue(EndSynchronizedUpdate)?.flush() diff --git a/src/term.rs b/src/term.rs index 9242919..51fcad1 100644 --- a/src/term.rs +++ b/src/term.rs @@ -15,9 +15,83 @@ thread_local! { static VS_CODE: Cell = Cell::new(env::var_os("TERM_PROGRAM").is_some_and(|v| v == "vscode")); } +pub struct MaxLenWriter<'a, 'b> { + pub stdout: &'a mut StdoutLock<'b>, + len: usize, + max_len: usize, +} + +impl<'a, 'b> MaxLenWriter<'a, 'b> { + #[inline] + pub fn new(stdout: &'a mut StdoutLock<'b>, max_len: usize) -> Self { + Self { + stdout, + len: 0, + max_len, + } + } + + // Additional is for emojis that take more space. + #[inline] + pub fn add_to_len(&mut self, additional: usize) { + self.len += additional; + } +} + +pub trait CountedWrite<'a> { + fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()>; + fn write_str(&mut self, unicode: &str) -> io::Result<()>; + fn stdout(&mut self) -> &mut StdoutLock<'a>; +} + +impl<'a, 'b> CountedWrite<'b> for MaxLenWriter<'a, 'b> { + fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()> { + let n = ascii.len().min(self.max_len.saturating_sub(self.len)); + self.stdout.write_all(&ascii[..n])?; + self.len += n; + Ok(()) + } + + fn write_str(&mut self, unicode: &str) -> io::Result<()> { + if let Some((ind, c)) = unicode + .char_indices() + .take(self.max_len.saturating_sub(self.len)) + .last() + { + self.stdout + .write_all(&unicode.as_bytes()[..ind + c.len_utf8()])?; + self.len += ind + 1; + } + + Ok(()) + } + + #[inline] + fn stdout(&mut self) -> &mut StdoutLock<'b> { + self.stdout + } +} + +impl<'a> CountedWrite<'a> for StdoutLock<'a> { + #[inline] + fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()> { + self.write_all(ascii) + } + + #[inline] + fn write_str(&mut self, unicode: &str) -> io::Result<()> { + self.write_all(unicode.as_bytes()) + } + + #[inline] + fn stdout(&mut self) -> &mut StdoutLock<'a> { + self + } +} + /// Terminal progress bar to be used when not using Ratataui. -pub fn progress_bar( - stdout: &mut StdoutLock, +pub fn progress_bar<'a>( + writer: &mut impl CountedWrite<'a>, progress: u16, total: u16, line_width: u16, @@ -32,9 +106,13 @@ pub fn progress_bar( const MIN_LINE_WIDTH: u16 = WRAPPER_WIDTH + 4; if line_width < MIN_LINE_WIDTH { - return write!(stdout, "Progress: {progress}/{total} exercises"); + writer.write_ascii(b"Progress: ")?; + // Integers are in ASCII. + writer.write_ascii(format!("{progress}/{total}").as_bytes())?; + return writer.write_ascii(b" exercises"); } + let stdout = writer.stdout(); stdout.write_all(PREFIX)?; let width = line_width - WRAPPER_WIDTH; @@ -77,16 +155,20 @@ pub fn press_enter_prompt(stdout: &mut StdoutLock) -> io::Result<()> { Ok(()) } -pub fn terminal_file_link(stdout: &mut StdoutLock, path: &str, color: Color) -> io::Result<()> { +pub fn terminal_file_link<'a>( + writer: &mut impl CountedWrite<'a>, + path: &str, + color: Color, +) -> io::Result<()> { // VS Code shows its own links. This also avoids some issues, especially on Windows. if VS_CODE.get() { - return stdout.write_all(path.as_bytes()); + return writer.write_str(path); } let canonical_path = fs::canonicalize(path).ok(); let Some(canonical_path) = canonical_path.as_deref().and_then(|p| p.to_str()) else { - return stdout.write_all(path.as_bytes()); + return writer.write_str(path); }; // Windows itself can't handle its verbatim paths. @@ -97,14 +179,18 @@ pub fn terminal_file_link(stdout: &mut StdoutLock, path: &str, color: Color) -> canonical_path }; - stdout + writer + .stdout() .queue(SetForegroundColor(color))? .queue(SetAttribute(Attribute::Underlined))?; - write!( - stdout, - "\x1b]8;;file://{canonical_path}\x1b\\{path}\x1b]8;;\x1b\\", - )?; - stdout + writer.stdout().write_all(b"\x1b]8;;file://")?; + writer.stdout().write_all(canonical_path.as_bytes())?; + writer.stdout().write_all(b"\x1b\\")?; + // Only this part is visible. + writer.write_str(path)?; + writer.stdout().write_all(b"\x1b]8;;\x1b\\")?; + writer + .stdout() .queue(SetForegroundColor(Color::Reset))? .queue(SetAttribute(Attribute::NoUnderline))?;