diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index e43d27f22..a7961aaa8 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -4,6 +4,7 @@ use crate::core_editor::get_system_clipboard; use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; use crate::prompt::{PromptEditMode, PromptViMode}; use crate::{core_editor::get_local_clipboard, EditCommand}; +use std::cmp::{max, min}; use std::ops::{DerefMut, Range}; /// Stateful editor executing changes to the underlying [`LineBuffer`] @@ -55,6 +56,9 @@ impl Editor { match command { EditCommand::MoveToStart { select } => self.move_to_start(*select), EditCommand::MoveToLineStart { select } => self.move_to_line_start(*select), + EditCommand::MoveToLineNonBlankStart { select } => { + self.move_to_line_non_blank_start(*select) + } EditCommand::MoveToEnd { select } => self.move_to_end(*select), EditCommand::MoveToLineEnd { select } => self.move_to_line_end(*select), EditCommand::MoveToPosition { position, select } => { @@ -86,8 +90,15 @@ impl Editor { EditCommand::ClearToLineEnd => self.line_buffer.clear_to_line_end(), EditCommand::CutCurrentLine => self.cut_current_line(), EditCommand::CutFromStart => self.cut_from_start(), + EditCommand::CutFromStartLinewise { leave_blank_line } => { + self.cut_from_start_linewise(*leave_blank_line) + } EditCommand::CutFromLineStart => self.cut_from_line_start(), + EditCommand::CutFromLineNonBlankStart => self.cut_from_line_non_blank_start(), EditCommand::CutToEnd => self.cut_from_end(), + EditCommand::CutToEndLinewise { leave_blank_line } => { + self.cut_from_end_linewise(*leave_blank_line) + } EditCommand::CutToLineEnd => self.cut_to_line_end(), EditCommand::KillLine => self.kill_line(), EditCommand::CutWordLeft => self.cut_word_left(), @@ -127,8 +138,11 @@ impl Editor { EditCommand::CopySelection => self.copy_selection_to_cut_buffer(), EditCommand::Paste => self.paste_cut_buffer(), EditCommand::CopyFromStart => self.copy_from_start(), + EditCommand::CopyFromStartLinewise => self.copy_from_start_linewise(), EditCommand::CopyFromLineStart => self.copy_from_line_start(), + EditCommand::CopyFromLineNonBlankStart => self.copy_from_line_non_blank_start(), EditCommand::CopyToEnd => self.copy_from_end(), + EditCommand::CopyToEndLinewise => self.copy_from_end_linewise(), EditCommand::CopyToLineEnd => self.copy_to_line_end(), EditCommand::CopyWordLeft => self.copy_word_left(), EditCommand::CopyBigWordLeft => self.copy_big_word_left(), @@ -308,6 +322,11 @@ impl Editor { self.line_buffer.move_to_line_start(); } + pub(crate) fn move_to_line_non_blank_start(&mut self, select: bool) { + self.update_selection_anchor(select); + self.line_buffer.move_to_line_non_blank_start(); + } + pub(crate) fn move_to_line_end(&mut self, select: bool) { self.update_selection_anchor(select); self.line_buffer.move_to_line_end(); @@ -357,6 +376,29 @@ impl Editor { } } + fn cut_from_start_linewise(&mut self, leave_blank_line: bool) { + let insertion_offset = self.line_buffer.insertion_point(); + let end_offset = self.line_buffer.get_buffer()[insertion_offset..] + .find('\n') + .map_or(self.line_buffer.len(), |offset| { + // When leave_blank_line is true, we do **not** add 1 to the offset + // So there will remain an empty line after the operation + if leave_blank_line { + insertion_offset + offset + } else { + insertion_offset + offset + 1 + } + }); + if end_offset > 0 { + self.cut_buffer.set( + &self.line_buffer.get_buffer()[..end_offset], + ClipboardMode::Lines, + ); + self.line_buffer.clear_range(..end_offset); + self.line_buffer.move_to_start(); + } + } + fn cut_from_line_start(&mut self) { let previous_offset = self.line_buffer.insertion_point(); self.line_buffer.move_to_line_start(); @@ -368,6 +410,14 @@ impl Editor { } } + fn cut_from_line_non_blank_start(&mut self) { + let cursor_pos = self.line_buffer.insertion_point(); + self.line_buffer.move_to_line_non_blank_start(); + let other_pos = self.line_buffer.insertion_point(); + let deletion_range = min(cursor_pos, other_pos)..max(cursor_pos, other_pos); + self.cut_range(deletion_range); + } + fn cut_from_end(&mut self) { let cut_slice = &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..]; if !cut_slice.is_empty() { @@ -376,6 +426,27 @@ impl Editor { } } + fn cut_from_end_linewise(&mut self, leave_blank_line: bool) { + let start_offset = self.line_buffer.get_buffer()[..self.line_buffer.insertion_point()] + .rfind('\n') + .map_or(0, |offset| { + // When leave_blank_line is true, we add 1 to the offset + // So the \n character is not truncated + if leave_blank_line { + offset + 1 + } else { + offset + } + }); + + let cut_slice = &self.line_buffer.get_buffer()[start_offset..]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Lines); + self.line_buffer.set_insertion_point(start_offset); + self.line_buffer.clear_to_end(); + } + } + fn cut_to_line_end(&mut self) { let cut_slice = &self.line_buffer.get_buffer() [self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end()]; @@ -876,6 +947,20 @@ impl Editor { } } + pub(crate) fn copy_from_start_linewise(&mut self) { + let insertion_point = self.line_buffer.insertion_point(); + let end_offset = self.line_buffer.get_buffer()[insertion_point..] + .find('\n') + .map_or(self.line_buffer.len(), |offset| insertion_point + offset); + if end_offset > 0 { + self.cut_buffer.set( + &self.line_buffer.get_buffer()[..end_offset], + ClipboardMode::Lines, + ); + } + self.line_buffer.move_to_start(); + } + pub(crate) fn copy_from_line_start(&mut self) { let previous_offset = self.line_buffer.insertion_point(); let start_offset = { @@ -889,11 +974,29 @@ impl Editor { self.copy_range(copy_range); } + pub(crate) fn copy_from_line_non_blank_start(&mut self) { + let cursor_pos = self.line_buffer.insertion_point(); + self.line_buffer.move_to_line_non_blank_start(); + let other_pos = self.line_buffer.insertion_point(); + self.line_buffer.set_insertion_point(cursor_pos); + let copy_range = min(cursor_pos, other_pos)..max(cursor_pos, other_pos); + self.copy_range(copy_range); + } + pub(crate) fn copy_from_end(&mut self) { let copy_range = self.line_buffer.insertion_point()..self.line_buffer.len(); self.copy_range(copy_range); } + pub(crate) fn copy_from_end_linewise(&mut self) { + self.line_buffer.move_to_line_start(); + let copy_range = self.line_buffer.insertion_point()..self.line_buffer.len(); + if copy_range.start < copy_range.end { + let slice = &self.line_buffer.get_buffer()[copy_range]; + self.cut_buffer.set(slice, ClipboardMode::Lines); + } + } + pub(crate) fn copy_to_line_end(&mut self) { let copy_range = self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 7886b4fb6..8451d6635 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -113,6 +113,18 @@ impl LineBuffer { // str is guaranteed to be utf8, thus \n is safe to assume 1 byte long } + /// Move the cursor before the first non whitespace character of the line + pub fn move_to_line_non_blank_start(&mut self) { + let line_start = self.lines[..self.insertion_point] + .rfind('\n') + .map_or(0, |offset| offset + 1); + // str is guaranteed to be utf8, thus \n is safe to assume 1 byte long + + self.insertion_point = self.lines[line_start..] + .find(|c: char| !c.is_whitespace() || c == '\n') + .map_or(self.lines.len(), |offset| line_start + offset); + } + /// Move cursor position to the end of the line /// /// Insertion will append to the line. diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 8c856418d..5f8bfddb1 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -344,10 +344,23 @@ impl Command { Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) } Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]), + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromLineNonBlankStart, + )]), Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), Motion::Up => None, Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromStartLinewise { + leave_blank_line: false, + }, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CutToEndLinewise { + leave_blank_line: false, + })]) + } Motion::ReplayCharSearch => vi_state .last_char_search .as_ref() @@ -399,10 +412,23 @@ impl Command { Motion::Start => { Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]) } + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromLineNonBlankStart, + )]), Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), Motion::Up => None, Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CutFromStartLinewise { + leave_blank_line: true, + }, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CutToEndLinewise { + leave_blank_line: true, + })]) + } Motion::ReplayCharSearch => vi_state .last_char_search .as_ref() @@ -453,10 +479,19 @@ impl Command { Some(vec![ReedlineOption::Edit(EditCommand::CopyLeftBefore(*c))]) } Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CopyFromLineStart)]), + Motion::NonBlankStart => Some(vec![ReedlineOption::Edit( + EditCommand::CopyFromLineNonBlankStart, + )]), Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::CopyLeft)]), Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::CopyRight)]), Motion::Up => None, Motion::Down => None, + Motion::FirstLine => Some(vec![ReedlineOption::Edit( + EditCommand::CopyFromStartLinewise, + )]), + Motion::LastLine => { + Some(vec![ReedlineOption::Edit(EditCommand::CopyToEndLinewise)]) + } Motion::ReplayCharSearch => vi_state .last_char_search .as_ref() diff --git a/src/edit_mode/vi/motion.rs b/src/edit_mode/vi/motion.rs index a59edf3ee..99ea54daf 100644 --- a/src/edit_mode/vi/motion.rs +++ b/src/edit_mode/vi/motion.rs @@ -52,10 +52,14 @@ where let _ = input.next(); ParseResult::Valid(Motion::NextBigWordEnd) } - Some('0' | '^') => { + Some('0') => { let _ = input.next(); ParseResult::Valid(Motion::Start) } + Some('^') => { + let _ = input.next(); + ParseResult::Valid(Motion::NonBlankStart) + } Some('$') => { let _ = input.next(); ParseResult::Valid(Motion::End) @@ -108,6 +112,21 @@ where let _ = input.next(); ParseResult::Valid(Motion::ReverseCharSearch) } + Some('g') => { + let _ = input.next(); + match input.peek() { + Some('g') => { + input.next(); + ParseResult::Valid(Motion::FirstLine) + } + Some(_) => ParseResult::Invalid, + None => ParseResult::Incomplete, + } + } + Some('G') => { + let _ = input.next(); + ParseResult::Valid(Motion::LastLine) + } ch if ch == command_char.as_ref().as_ref() && command_char.is_some() => { let _ = input.next(); ParseResult::Valid(Motion::Line) @@ -131,7 +150,10 @@ pub enum Motion { PreviousBigWord, Line, Start, + NonBlankStart, End, + FirstLine, + LastLine, RightUntil(char), RightBefore(char), LeftUntil(char), @@ -191,9 +213,20 @@ impl Motion { Motion::Start => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart { select: select_mode, })], + Motion::NonBlankStart => { + vec![ReedlineOption::Edit(EditCommand::MoveToLineNonBlankStart { + select: select_mode, + })] + } Motion::End => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd { select: select_mode, })], + Motion::FirstLine => vec![ReedlineOption::Edit(EditCommand::MoveToStart { + select: select_mode, + })], + Motion::LastLine => vec![ReedlineOption::Edit(EditCommand::MoveToEnd { + select: select_mode, + })], Motion::RightUntil(ch) => { vi_state.last_char_search = Some(ViCharSearch::ToRight(*ch)); vec![ReedlineOption::Edit(EditCommand::MoveRightUntil { diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index d7bebb8bc..a1715517c 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -541,20 +541,33 @@ mod tests { // #[case(&['d', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::Up, ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine]), ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] #[case(&['d', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight])]))] #[case(&['d', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart])]))] - #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart])]))] + #[case(&['d', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart])]))] #[case(&['d', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd])]))] #[case(&['d', 'f', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')])]))] #[case(&['d', 't', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightBefore('a')])]))] #[case(&['d', 'F', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')])]))] #[case(&['d', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')])]))] + #[case(&['d', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: false }])]))] + #[case(&['d', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: false }])]))] #[case(&['c', 'E'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRight]), ReedlineEvent::Repaint]))] #[case(&['c', '0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Repaint]))] - #[case(&['c', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineStart]), ReedlineEvent::Repaint]))] + #[case(&['c', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutFromLineNonBlankStart]), ReedlineEvent::Repaint]))] #[case(&['c', '$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutToLineEnd]), ReedlineEvent::Repaint]))] #[case(&['c', 'f', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightUntil('a')]), ReedlineEvent::Repaint]))] #[case(&['c', 't', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutRightBefore('a')]), ReedlineEvent::Repaint]))] #[case(&['c', 'F', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftUntil('a')]), ReedlineEvent::Repaint]))] #[case(&['c', 'T', 'a'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutLeftBefore('a')]), ReedlineEvent::Repaint]))] + #[case(&['c', 'g', 'g'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutFromStartLinewise { leave_blank_line: true }]), + ReedlineEvent::Repaint, + ]))] + #[case(&['c', 'G'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutToEndLinewise { leave_blank_line: true }]), + ReedlineEvent::Repaint, + ]))] + #[case(&['y', '^'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromLineNonBlankStart])]))] + #[case(&['y', 'g', 'g'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyFromStartLinewise])]))] + #[case(&['y', 'G'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CopyToEndLinewise])]))] fn test_reedline_move(#[case] input: &[char], #[case] expected: ReedlineEvent) { let mut vi = Vi::default(); let res = vi_parse(input); diff --git a/src/enums.rs b/src/enums.rs index 719ca9da6..bccaf96ab 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -94,6 +94,12 @@ pub enum EditCommand { select: bool, }, + /// Move to the start of the current line skipping any whitespace + MoveToLineNonBlankStart { + /// Select the text between the current cursor position and destination + select: bool, + }, + /// Move to the end of the buffer MoveToEnd { /// Select the text between the current cursor position and destination @@ -216,12 +222,27 @@ pub enum EditCommand { /// Cut from the start of the buffer to the insertion point CutFromStart, + /// Cut from the start of the buffer to the line of insertion point + CutFromStartLinewise { + /// When true, an empty line will remain after the operation + leave_blank_line: bool, + }, + /// Cut from the start of the current line to the insertion point CutFromLineStart, + /// Cut from the first non whitespace character of the current line to the insertion point + CutFromLineNonBlankStart, + /// Cut from the insertion point to the end of the buffer CutToEnd, + /// Cut from the line of insertion point to the end of the buffer + CutToEndLinewise { + /// When true, an empty line will remain after the operation + leave_blank_line: bool, + }, + /// Cut from the insertion point to the end of the current line CutToLineEnd, @@ -336,12 +357,21 @@ pub enum EditCommand { /// Copy from the start of the buffer to the insertion point CopyFromStart, + /// Copy from the start of the buffer to the line of insertion point + CopyFromStartLinewise, + /// Copy from the start of the current line to the insertion point CopyFromLineStart, + /// Copy from the first non whitespace character of the current line to the insertion point + CopyFromLineNonBlankStart, + /// Copy from the insertion point to the end of the buffer CopyToEnd, + /// Copy from the line of insertion point to the end of the buffer + CopyToEndLinewise, + /// Copy from the insertion point to the end of the current line CopyToLineEnd, @@ -445,6 +475,9 @@ impl Display for EditCommand { EditCommand::MoveToLineStart { .. } => { write!(f, "MoveToLineStart Optional[select: ]") } + EditCommand::MoveToLineNonBlankStart { .. } => { + write!(f, "MoveToLineNonBlankStart Optional[select: ]") + } EditCommand::MoveToEnd { .. } => write!(f, "MoveToEnd Optional[select: ]"), EditCommand::MoveToLineEnd { .. } => { write!(f, "MoveToLineEnd Optional[select: ]") @@ -494,8 +527,15 @@ impl Display for EditCommand { EditCommand::Complete => write!(f, "Complete"), EditCommand::CutCurrentLine => write!(f, "CutCurrentLine"), EditCommand::CutFromStart => write!(f, "CutFromStart"), + EditCommand::CutFromStartLinewise { .. } => { + write!(f, "CutFromStartLinewise Value: ") + } EditCommand::CutFromLineStart => write!(f, "CutFromLineStart"), + EditCommand::CutFromLineNonBlankStart => write!(f, "CutFromLineNonBlankStart"), EditCommand::CutToEnd => write!(f, "CutToEnd"), + EditCommand::CutToEndLinewise { .. } => { + write!(f, "CutToEndLinewise Value: ") + } EditCommand::CutToLineEnd => write!(f, "CutToLineEnd"), EditCommand::KillLine => write!(f, "KillLine"), EditCommand::CutWordLeft => write!(f, "CutWordLeft"), @@ -525,8 +565,11 @@ impl Display for EditCommand { EditCommand::CopySelection => write!(f, "CopySelection"), EditCommand::Paste => write!(f, "Paste"), EditCommand::CopyFromStart => write!(f, "CopyFromStart"), + EditCommand::CopyFromStartLinewise => write!(f, "CopyFromStartLinewise"), EditCommand::CopyFromLineStart => write!(f, "CopyFromLineStart"), + EditCommand::CopyFromLineNonBlankStart => write!(f, "CopyFromLineNonBlankStart"), EditCommand::CopyToEnd => write!(f, "CopyToEnd"), + EditCommand::CopyToEndLinewise => write!(f, "CopyToEndLinewise"), EditCommand::CopyToLineEnd => write!(f, "CopyToLineEnd"), EditCommand::CopyCurrentLine => write!(f, "CopyCurrentLine"), EditCommand::CopyWordLeft => write!(f, "CopyWordLeft"), @@ -568,6 +611,7 @@ impl EditCommand { | EditCommand::MoveToEnd { select, .. } | EditCommand::MoveToLineStart { select, .. } | EditCommand::MoveToLineEnd { select, .. } + | EditCommand::MoveToLineNonBlankStart { select, .. } | EditCommand::MoveToPosition { select, .. } | EditCommand::MoveLeft { select, .. } | EditCommand::MoveRight { select, .. } @@ -603,10 +647,13 @@ impl EditCommand { | EditCommand::Complete | EditCommand::CutCurrentLine | EditCommand::CutFromStart + | EditCommand::CutFromStartLinewise { .. } | EditCommand::CutFromLineStart + | EditCommand::CutFromLineNonBlankStart | EditCommand::CutToLineEnd | EditCommand::KillLine | EditCommand::CutToEnd + | EditCommand::CutToEndLinewise { .. } | EditCommand::CutWordLeft | EditCommand::CutBigWordLeft | EditCommand::CutWordRight @@ -640,8 +687,11 @@ impl EditCommand { #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => EditType::NoOp, EditCommand::CopyFromStart + | EditCommand::CopyFromStartLinewise | EditCommand::CopyFromLineStart + | EditCommand::CopyFromLineNonBlankStart | EditCommand::CopyToEnd + | EditCommand::CopyToEndLinewise | EditCommand::CopyToLineEnd | EditCommand::CopyCurrentLine | EditCommand::CopyWordLeft