use unicode_width::UnicodeWidthChar;
pub const CARET: char = '|';
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PathField {
value: Vec<char>,
committed: Vec<char>,
cursor: usize,
editing: bool,
}
impl PathField {
#[must_use]
pub fn new(value: &str) -> Self {
let value: Vec<char> = value.chars().collect();
Self {
committed: value.clone(),
cursor: value.len(),
value,
editing: false,
}
}
pub fn reset_to(&mut self, value: &str) {
*self = Self::new(value);
}
#[must_use]
pub fn text(&self) -> String {
self.value.iter().collect()
}
#[must_use]
pub fn is_blank(&self) -> bool {
self.value.iter().all(|c| c.is_whitespace())
}
#[must_use]
pub const fn is_editing(&self) -> bool {
self.editing
}
#[cfg(test)]
#[must_use]
pub const fn cursor(&self) -> usize {
self.cursor
}
pub fn begin(&mut self) {
self.committed = self.value.clone();
self.cursor = self.value.len();
self.editing = true;
}
pub fn accept(&mut self) {
self.committed = self.value.clone();
self.editing = false;
}
pub fn cancel(&mut self) {
self.value = self.committed.clone();
self.cursor = self.value.len();
self.editing = false;
}
pub fn insert(&mut self, character: char) {
if character.is_control() {
return;
}
self.value.insert(self.cursor, character);
self.cursor += 1;
}
pub fn paste(&mut self, text: &str) {
let first = text.lines().next().unwrap_or_default().trim();
let unquoted = first
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(first);
for character in unquoted.chars() {
self.insert(character);
}
}
pub fn backspace(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.value.remove(self.cursor);
}
}
pub fn delete(&mut self) {
if self.cursor < self.value.len() {
self.value.remove(self.cursor);
}
}
pub const fn left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub const fn right(&mut self) {
if self.cursor < self.value.len() {
self.cursor += 1;
}
}
pub const fn home(&mut self) {
self.cursor = 0;
}
pub const fn end(&mut self) {
self.cursor = self.value.len();
}
#[must_use]
pub fn view(&self, columns: usize) -> PathView {
let mut rendered: Vec<char> = self.value.clone();
let caret_at = self.cursor;
if self.editing {
rendered.insert(caret_at, CARET);
}
let budget = columns.max(1);
let anchor = if self.editing {
caret_at
} else {
rendered.len().saturating_sub(1)
};
let mut start = anchor.min(rendered.len().saturating_sub(1));
let mut used = width_of(rendered.get(start).copied());
while start > 0 {
let candidate = width_of(rendered.get(start - 1).copied());
if used + candidate > budget {
break;
}
used += candidate;
start -= 1;
}
let mut end = anchor.saturating_add(1).min(rendered.len());
while end < rendered.len() {
let candidate = width_of(rendered.get(end).copied());
if used + candidate > budget {
break;
}
used += candidate;
end += 1;
}
PathView {
text: rendered[start..end].iter().collect(),
clipped_left: start > 0,
clipped_right: end < rendered.len(),
}
}
}
fn width_of(character: Option<char>) -> usize {
character.and_then(UnicodeWidthChar::width).unwrap_or(0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathView {
pub text: String,
pub clipped_left: bool,
pub clipped_right: bool,
}
impl PathView {
#[must_use]
pub fn rendered(&self) -> String {
format!(
"{}{}{}",
if self.clipped_left { "<" } else { "" },
self.text,
if self.clipped_right { ">" } else { "" }
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn field(value: &str) -> PathField {
let mut field = PathField::new(value);
field.begin();
field
}
#[test]
fn typing_backspace_delete_and_the_four_motions_edit_the_value() {
let mut path = field("C:\\rman");
path.insert('x');
assert_eq!(path.text(), "C:\\rmanx");
path.backspace();
assert_eq!(path.text(), "C:\\rman");
path.home();
assert_eq!(path.cursor(), 0);
path.delete();
assert_eq!(path.text(), ":\\rman");
path.right();
path.insert('!');
assert_eq!(path.text(), ":!\\rman");
path.left();
path.delete();
assert_eq!(path.text(), ":\\rman");
path.end();
assert_eq!(path.cursor(), path.text().chars().count());
path.home();
path.left();
assert_eq!(path.cursor(), 0);
path.end();
path.right();
assert_eq!(path.cursor(), 6);
}
#[test]
fn escape_restores_the_value_and_enter_keeps_the_draft() {
let mut path = field("C:\\rman");
path.insert('2');
path.cancel();
assert_eq!(path.text(), "C:\\rman");
assert!(!path.is_editing());
path.begin();
path.insert('2');
path.accept();
assert_eq!(path.text(), "C:\\rman2");
assert!(!path.is_editing());
path.begin();
path.insert('3');
path.cancel();
assert_eq!(path.text(), "C:\\rman2");
}
#[test]
fn paste_takes_one_line_and_unwraps_the_shell_quoting_around_a_path() {
let mut path = field("");
path.paste("\"D:\\ci cache\\project\"\r\nignored second line\n");
assert_eq!(path.text(), "D:\\ci cache\\project");
let mut path = field("");
path.paste("D:\\wei\"rd");
assert_eq!(path.text(), "D:\\wei\"rd");
let mut path = field("");
path.paste("D:\\a\u{7}b");
assert_eq!(path.text(), "D:\\ab");
}
#[test]
fn a_long_value_scrolls_horizontally_around_the_cursor() {
let long = format!("D:\\{}\\slots", "segment".repeat(12));
let mut path = field(&long);
let tail = path.view(20);
assert!(tail.clipped_left, "{tail:?}");
assert!(!tail.clipped_right, "{tail:?}");
assert!(tail.text.ends_with("slots|"), "{tail:?}");
assert!(tail.rendered().starts_with('<'), "{tail:?}");
path.home();
let head = path.view(20);
assert!(!head.clipped_left, "{head:?}");
assert!(head.clipped_right, "{head:?}");
assert!(head.text.starts_with("|D:\\"), "{head:?}");
assert!(head.rendered().ends_with('>'), "{head:?}");
for columns in 1..40 {
let view = path.view(columns);
assert!(
view.text.chars().count() <= columns,
"{columns}: {:?}",
view.text
);
}
}
#[test]
fn a_short_value_is_never_clipped_and_shows_no_caret_when_not_editing() {
let mut path = PathField::new("C:\\rman");
let view = path.view(40);
assert_eq!(view.rendered(), "C:\\rman");
assert!(!view.clipped_left && !view.clipped_right);
path.begin();
assert_eq!(path.view(40).rendered(), "C:\\rman|");
path.home();
assert_eq!(path.view(40).rendered(), "|C:\\rman");
}
#[test]
fn an_empty_field_renders_nothing_and_reports_blank() {
let mut path = PathField::new("");
assert!(path.is_blank());
assert_eq!(path.view(10).rendered(), "");
path.begin();
assert_eq!(path.view(10).rendered(), "|");
path.insert(' ');
assert!(path.is_blank(), "whitespace is not a configured path");
}
}