From 2e7e8a3ee424db1544289453de0f0f837a73361e Mon Sep 17 00:00:00 2001 From: Brad Culwell Date: Tue, 25 Jun 2024 11:23:19 +0000 Subject: [PATCH] feat(input): truncate with ellipsis with full left/right navigation --- src/util/strings.rs | 137 +++++++++++++++++++++++++++++++++ src/widget/input.rs | 181 ++++++++------------------------------------ 2 files changed, 170 insertions(+), 148 deletions(-) create mode 100644 src/util/strings.rs diff --git a/src/util/strings.rs b/src/util/strings.rs new file mode 100644 index 0000000..b4a9527 --- /dev/null +++ b/src/util/strings.rs @@ -0,0 +1,137 @@ +use std::{collections::VecDeque, ops::RangeBounds}; + +use unicode_width::UnicodeWidthChar as _; + +pub fn pos_of_nth_char(s: &String, idx: usize) -> usize { + s.chars() + .take(idx) + .fold(0, |acc, c| acc + c.width().unwrap_or(0)) +} + +pub fn without_nth_char(s: &String, idx: usize) -> String { + s.chars() + .enumerate() + .filter_map(|(i, c)| if i != idx { Some(c) } else { None }) + .collect::() +} + +pub fn without_range(s: &String, range: impl RangeBounds) -> String { + let mut vec = s.chars().collect::>(); + vec.drain(range); + vec.into_iter().collect() +} + +pub fn insert_char(s: &String, idx: usize, x: char) -> String { + let mut vec = s.chars().collect::>(); + vec.insert(idx, x); + vec.into_iter().collect() +} + +pub fn truncate_ellipsis( + s: String, + n: usize, + cursor: usize, + offset: &mut usize, +) -> (Option, String, Option) { + let (mut sum, mut before) = (0, 0); + + if cursor >= *offset + n { + *offset = cursor.saturating_sub(n) + 1; + } else if cursor <= *offset { + *offset = cursor.saturating_sub(1); + } + + let total_width = s.chars().fold(0, |acc, c| acc + c.width().unwrap_or(0)); + let mut chars = s + .chars() + // Skip `offset` number of columns + .skip_while(|x| { + let add = before + x.width().unwrap_or(0); + let res = add <= *offset; + if res { + before = add; + } + res + }) + // Collect `n` number of columns + .take_while(|x| { + let add = sum + x.width().unwrap_or(0); + let res = add <= n; + if res { + sum = add; + } + res + }) + .collect::>(); + + // Gap left by cut-off wide characters + let gap = offset.saturating_sub(before); + + // Show ellipsis if characters are hidden to the left + let el = (*offset > 0).then(|| { + // Remove first (visible) character, replace with ellipsis + let repeat = chars + .pop_front() + .and_then(|c| c.width()) + .unwrap_or(0) + .saturating_sub(gap); + ['…'].repeat(repeat).iter().collect() + }); + + let gap = n.saturating_sub(sum) + gap; + + // Show ellipsis if characters are hidden to the right + let er = (*offset + n < total_width + 1).then(|| { + // Remove last (visible) character, replace with ellipsis + let repeat = if gap > 0 { + gap + } else { + // Only pop last char if no gap + chars.pop_back().and_then(|c| c.width()).unwrap_or(0) + }; + ['…'].repeat(repeat).iter().collect() + }); + + return (el, chars.iter().collect::(), er); +} + +pub fn back_word(input: &String, start: usize) -> usize { + let cursor = start.min(input.chars().count()); + // Find the first non-space character before the cursor + let first_non_space = input + .chars() + .take(cursor) + .collect::>() + .into_iter() + .rposition(|c| c != ' ') + .unwrap_or(0); + + // Find the first space character before the first non-space character + input + .chars() + .take(first_non_space) + .collect::>() + .into_iter() + .rposition(|c| c == ' ') + .map(|u| u + 1) + .unwrap_or(0) +} + +pub fn forward_word(input: &String, start: usize) -> usize { + let idx = start.min(input.chars().count()); + + // Skip all non-whitespace + let nonws = input + .chars() + .skip(idx) + .position(|c| c.is_whitespace()) + .map(|n| n + idx) + .unwrap_or(input.chars().count()); + // Then skip all whitespace, starting from last non-whitespace + input + .chars() + .skip(nonws) + .position(|c| !c.is_whitespace()) + .map(|n| n + nonws) + .unwrap_or(input.chars().count()) +} diff --git a/src/widget/input.rs b/src/widget/input.rs index 149ad00..2e02adf 100644 --- a/src/widget/input.rs +++ b/src/widget/input.rs @@ -1,7 +1,4 @@ -use std::{ - cmp::{max, min}, - ops::RangeBounds, -}; +use std::cmp::{max, min}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ @@ -11,13 +8,13 @@ use ratatui::{ widgets::{Paragraph, Widget}, Frame, }; -use unicode_width::UnicodeWidthChar; -use crate::app::Context; +use crate::{app::Context, util::strings}; pub struct InputWidget { pub input: String, pub char_idx: usize, + pub char_offset: usize, pub cursor: usize, pub max_len: usize, pub validator: Option bool>, @@ -28,6 +25,7 @@ impl InputWidget { InputWidget { input: "".to_owned(), char_idx: 0, + char_offset: 0, cursor: 0, max_len, validator, @@ -35,23 +33,14 @@ impl InputWidget { } pub fn show_cursor(&self, f: &mut Frame, area: Rect) { - let width = area.width as usize; - let widths: Vec = self.input.chars().map(|c| c.width().unwrap_or(0)).collect(); - let visible_width = widths.iter().rfold(0, |sum, x| { - sum + (sum + *x < width).then_some(*x).unwrap_or(0) - }); - let total_width: usize = widths.iter().sum(); - let hidden_width = total_width.saturating_sub(visible_width); - let left_over = (total_width > visible_width) - .then_some(width.saturating_sub(visible_width)) - .unwrap_or(0); - let cursor = self.cursor.saturating_sub(hidden_width) + left_over; + let cursor = self.get_cursor_pos(); + f.set_cursor(min(area.x + cursor as u16, area.x + area.width), area.y); } pub fn set_cursor(&mut self, idx: usize) { self.char_idx = idx.min(self.max_len); - self.cursor = pos_of_nth_char(&self.input, self.char_idx); + self.cursor = strings::pos_of_nth_char(&self.input, self.char_idx); } pub fn clear(&mut self) { @@ -59,57 +48,9 @@ impl InputWidget { self.cursor = 0; self.char_idx = 0; } -} - -fn pos_of_nth_char(s: &String, idx: usize) -> usize { - s.chars() - .take(idx) - .fold(0, |acc, c| acc + c.width().unwrap_or(0)) -} - -fn without_nth_char(s: &String, idx: usize) -> String { - s.chars() - .enumerate() - .filter_map(|(i, c)| if i != idx { Some(c) } else { None }) - .collect::() -} -fn without_range(s: &String, range: impl RangeBounds) -> String { - let mut vec = s.chars().collect::>(); - vec.drain(range); - vec.into_iter().collect() -} - -fn insert_char(s: &String, idx: usize, x: char) -> String { - let mut vec = s.chars().collect::>(); - vec.insert(idx, x); - vec.into_iter().collect() -} - -fn truncate_ellipsis(s: String, n: usize) -> (Option, String) { - let mut sum = 0; - // Get all characters that are can fit into n - let mut chars = s - .chars() - .rev() - .take_while(|x| { - let add = sum + x.width().unwrap_or(0); - let res = add < n; - if res { - sum = add; - } - res - }) - .collect::>(); - // If we cannot fit all characters into the given width, show ellipsis - if s.chars().count() > chars.len() { - let repeat = n - .checked_sub(sum) - .unwrap_or_else(|| chars.pop().and_then(|c| c.width()).unwrap_or(0)); - let ellipsis = ['…'].repeat(repeat).iter().collect::(); - (Some(ellipsis), chars.into_iter().rev().collect()) - } else { - (None, s) + fn get_cursor_pos(&self) -> usize { + return self.cursor - self.char_offset; } } @@ -117,15 +58,18 @@ impl super::Widget for InputWidget { fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { let fwidth = area.width as usize; // Try to insert ellipsis if input is too long (visual only) - let (ellipsis, visible) = truncate_ellipsis(self.input.clone(), fwidth); - let p = match ellipsis { - Some(e) => Paragraph::new(Line::from(vec![ - e.fg(ctx.theme.border_color), - visible.into(), - ])), - None => Paragraph::new(visible), - }; - p.render(area, f.buffer_mut()); + let (ellipsis, visible, ellipsis_back) = strings::truncate_ellipsis( + self.input.clone(), + fwidth, + self.cursor, + &mut self.char_offset, + ); + Paragraph::new(Line::from(vec![ + ellipsis.unwrap_or_default().fg(ctx.theme.border_color), + visible.into(), + ellipsis_back.unwrap_or_default().fg(ctx.theme.border_color), + ])) + .render(area, f.buffer_mut()); } fn handle_event(&mut self, _ctx: &mut Context, evt: &Event) { @@ -145,93 +89,34 @@ impl super::Widget for InputWidget { } } if self.input.chars().count() < self.max_len { - self.input = insert_char(&self.input, self.char_idx, *c); + self.input = strings::insert_char(&self.input, self.char_idx, *c); self.char_idx += 1; } } (Char('b') | Left, &KeyModifiers::CONTROL) => { - let cursor = min(self.char_idx, self.input.chars().count()); - // Find the first non-space character before the cursor - let non_space = self - .input - .chars() - .take(cursor) - .collect::>() - .into_iter() - .rposition(|c| c != ' ') - .unwrap_or(0); - - // Find the first space character before the first non-space character - self.char_idx = self - .input - .chars() - .take(non_space) - .collect::>() - .into_iter() - .rposition(|c| c == ' ') - .map(|u| u + 1) - .unwrap_or(0); + self.char_idx = strings::back_word(&self.input, self.char_idx); } (Char('w') | Right, &KeyModifiers::CONTROL) => { - let idx = min(self.char_idx + 1, self.input.chars().count()); - - self.char_idx = self - .input - .chars() - .skip(idx) - .collect::>() - .into_iter() - .position(|c| c == ' ') - .map(|u| self.char_idx + u + 2) - .unwrap_or(self.input.chars().count()); + self.char_idx = strings::forward_word(&self.input, self.char_idx); } (Delete, &KeyModifiers::CONTROL | &KeyModifiers::ALT) => { - let idx = min(self.char_idx + 1, self.input.chars().count()); - - let new_cursor = self - .input - .chars() - .skip(idx) - .collect::>() - .into_iter() - .position(|c| c == ' ') - .map(|u| self.char_idx + u + 2) - .unwrap_or(self.input.chars().count()); - self.input = without_range(&self.input, self.char_idx..new_cursor) + let new_cursor = strings::forward_word(&self.input, self.char_idx); + self.input = strings::without_range(&self.input, self.char_idx..new_cursor) } (Backspace, &KeyModifiers::CONTROL | &KeyModifiers::ALT) => { - let cursor = min(self.char_idx, self.input.chars().count()); - // Find the first non-space character before the cursor - let non_space = self - .input - .chars() - .take(cursor) - .collect::>() - .into_iter() - .rposition(|c| c != ' ') - .unwrap_or(0); - - // Find the first space character before the first non-space character - self.char_idx = self - .input - .chars() - .take(non_space) - .collect::>() - .into_iter() - .rposition(|c| c == ' ') - .map(|u| u + 1) - .unwrap_or(0); - self.input = without_range(&self.input, self.char_idx..cursor) + let new_cursor = strings::back_word(&self.input, self.char_idx); + self.input = strings::without_range(&self.input, new_cursor..self.char_idx); + self.char_idx = new_cursor; } (Backspace, &KeyModifiers::NONE) => { if !self.input.is_empty() && self.char_idx > 0 { self.char_idx -= 1; - self.input = without_nth_char(&self.input, self.char_idx); + self.input = strings::without_nth_char(&self.input, self.char_idx); } } (Delete, &KeyModifiers::NONE) => { if !self.input.is_empty() && self.char_idx < self.input.chars().count() { - self.input = without_nth_char(&self.input, self.char_idx); + self.input = strings::without_nth_char(&self.input, self.char_idx); } } (Left, &KeyModifiers::NONE) @@ -254,7 +139,7 @@ impl super::Widget for InputWidget { } _ => {} }; - self.cursor = pos_of_nth_char(&self.input, self.char_idx); + self.cursor = strings::pos_of_nth_char(&self.input, self.char_idx); } if let Event::Paste(p) = evt.to_owned() { let space_left = self.max_len - self.input.chars().count(); @@ -269,7 +154,7 @@ impl super::Widget for InputWidget { self.input = format!("{before}{p}{after}"); self.char_idx = min(self.char_idx + p.chars().count(), self.max_len); - self.cursor = pos_of_nth_char(&self.input, self.char_idx); + self.cursor = strings::pos_of_nth_char(&self.input, self.char_idx); } }