#[cfg(all(feature = "commandline", feature = "keybindings"))]
use crate::commandline::CommandLineSubmit;
#[cfg(feature = "commandline")]
use crate::commandline::{CommandLineCommand, CommandLineRegistry, CommandLineState};
#[cfg(feature = "cursor-style")]
use crate::cursor::CursorManager;
#[cfg(feature = "gui")]
use crate::gui_utils::{compute_h_scroll_with_padding, effective_right_pad};
#[cfg(feature = "keybindings")]
use crate::keybindings::CanvasKeyBindings;
#[cfg(all(feature = "commandline", feature = "keybindings"))]
use crate::keybindings::KeyEventOutcome;
use crate::textarea::provider::{TextAreaDataProvider, TextAreaProvider};
use crate::{
canvas::modes::AppMode,
canvas::state::{EditorState, SelectionState},
editor::EditorCore,
};
#[cfg(feature = "cursor-style")]
use std::io;
#[cfg(feature = "gui")]
use std::{fmt, str::FromStr};
#[cfg(feature = "gui")]
use ratatui::{layout::Rect, widgets::Block};
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
fn normalize_indent(width: u16, indent: u16) -> u16 {
indent.min(width.saturating_sub(1))
}
#[cfg(feature = "gui")]
pub(crate) fn continuation_prefix(width: u16, indent: u16) -> String {
if width == 0 {
return String::new();
}
let max_cols = width.saturating_sub(1) as usize;
let spaces = (indent as usize).min(max_cols.saturating_sub(1));
let mut prefix = " ".repeat(spaces);
if prefix.chars().count() < max_cols {
prefix.push('↪');
}
if prefix.chars().count() < max_cols {
prefix.push(' ');
}
prefix
}
#[cfg(feature = "gui")]
pub(crate) fn continuation_prefix_width(width: u16, indent: u16) -> u16 {
continuation_prefix(width, indent)
.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
.sum()
}
#[cfg(feature = "gui")]
pub(crate) fn count_wrapped_rows_indented(s: &str, width: u16, indent: u16) -> u16 {
if width == 0 {
return 1;
}
let indent = normalize_indent(width, indent);
let cont_prefix = continuation_prefix_width(width, indent);
let mut rows: u16 = 1;
let mut used: u16 = 0;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if used > 0 && used.saturating_add(w) > width {
rows = rows.saturating_add(1);
used = cont_prefix;
}
used = used.saturating_add(w);
}
rows
}
#[cfg(feature = "gui")]
fn wrapped_rows_to_cursor_indented(
s: &str,
width: u16,
indent: u16,
cursor_chars: usize,
) -> (u16, u16) {
if width == 0 {
return (0, 0);
}
let indent = normalize_indent(width, indent);
let cont_prefix = continuation_prefix_width(width, indent);
let mut row: u16 = 0;
let mut used: u16 = 0;
for (i, ch) in s.chars().enumerate() {
if i >= cursor_chars {
break;
}
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if used > 0 && used.saturating_add(w) > width {
row = row.saturating_add(1);
used = cont_prefix;
}
used = used.saturating_add(w);
}
let char_count = s.chars().count();
if cursor_chars > 0 && cursor_chars <= char_count && used >= width {
row = row.saturating_add(1);
used = cont_prefix;
}
(row, used.min(width.saturating_sub(1)))
}
#[cfg(feature = "gui")]
pub(crate) fn wrap_segment_ranges(s: &str, width: u16, indent: u16) -> Vec<(usize, usize)> {
if width == 0 {
return vec![(0, 0)];
}
let indent = normalize_indent(width, indent);
let cont_prefix = continuation_prefix_width(width, indent);
let mut ranges = Vec::new();
let mut used: u16 = 0;
let mut segment_start = 0;
let mut char_len = 0;
for (char_idx, ch) in s.chars().enumerate() {
char_len = char_idx + 1;
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if used > 0 && used.saturating_add(w) > width {
ranges.push((segment_start, char_idx));
segment_start = char_idx;
used = cont_prefix;
}
used = used.saturating_add(w);
}
ranges.push((segment_start, char_len));
ranges
}
#[cfg(feature = "gui")]
fn char_index_for_visual_col(
s: &str,
start: usize,
end: usize,
prefix_cols: u16,
target_col: u16,
) -> usize {
if start >= end {
return start;
}
if target_col <= prefix_cols {
return start;
}
let mut col = prefix_cols;
for (char_idx, ch) in s
.chars()
.enumerate()
.skip(start)
.take(end.saturating_sub(start))
{
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if col.saturating_add(w) > target_col {
return char_idx;
}
col = col.saturating_add(w);
}
end
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAreaEventOutcome {
Ignored,
Handled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextOverflowMode {
Indicator { ch: char },
Wrap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextAreaSearchMatch {
pub line: usize,
pub start: usize,
pub end: usize,
}
#[cfg(feature = "commandline")]
pub struct TextAreaCommandLineState {
state: CommandLineState,
commands: CommandLineRegistry,
}
#[cfg(feature = "commandline")]
impl Default for TextAreaCommandLineState {
fn default() -> Self {
Self {
state: CommandLineState::new(),
commands: default_textarea_commandline_commands(),
}
}
}
#[cfg(feature = "commandline")]
impl TextAreaCommandLineState {
pub fn state(&self) -> &CommandLineState {
&self.state
}
pub fn state_mut(&mut self) -> &mut CommandLineState {
&mut self.state
}
pub fn commands(&self) -> &CommandLineRegistry {
&self.commands
}
pub fn commands_mut(&mut self) -> &mut CommandLineRegistry {
&mut self.commands
}
}
#[cfg(feature = "commandline")]
fn default_textarea_commandline_commands() -> CommandLineRegistry {
let mut registry = CommandLineRegistry::new();
registry
.register(
CommandLineCommand::new("set-number")
.alias("number")
.alias("nu")
.pattern(["set", "number"])
.pattern(["set", "nu"]),
)
.unwrap()
.register(
CommandLineCommand::new("set-relative-number")
.alias("relativenumber")
.alias("rnu")
.pattern(["set", "relativenumber"])
.pattern(["set", "rnu"]),
)
.unwrap()
.register(
CommandLineCommand::new("set-no-number")
.alias("nonumber")
.alias("nonu")
.pattern(["set", "nonumber"])
.pattern(["set", "nonu"]),
)
.unwrap()
.register(
CommandLineCommand::new("no-highlight")
.alias("noh")
.alias("nohlsearch"),
)
.unwrap();
registry
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TextAreaLineNumberMode {
None,
Absolute,
Relative,
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseTextAreaLineNumberModeError {
name: String,
}
#[cfg(feature = "gui")]
impl ParseTextAreaLineNumberModeError {
pub fn name(&self) -> &str {
&self.name
}
}
#[cfg(feature = "gui")]
impl fmt::Display for ParseTextAreaLineNumberModeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown textarea line number mode {:?}", self.name)
}
}
#[cfg(feature = "gui")]
impl std::error::Error for ParseTextAreaLineNumberModeError {}
#[cfg(feature = "gui")]
impl TextAreaLineNumberMode {
pub fn as_str(self) -> &'static str {
match self {
TextAreaLineNumberMode::None => "none",
TextAreaLineNumberMode::Absolute => "absolute",
TextAreaLineNumberMode::Relative => "relative",
}
}
}
#[cfg(feature = "gui")]
impl FromStr for TextAreaLineNumberMode {
type Err = ParseTextAreaLineNumberModeError;
fn from_str(name: &str) -> Result<Self, Self::Err> {
match name {
"none" => Ok(TextAreaLineNumberMode::None),
"absolute" => Ok(TextAreaLineNumberMode::Absolute),
"relative" => Ok(TextAreaLineNumberMode::Relative),
_ => Err(ParseTextAreaLineNumberModeError {
name: name.to_string(),
}),
}
}
}
#[cfg(feature = "gui")]
impl fmt::Display for TextAreaLineNumberMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(all(test, feature = "gui"))]
mod line_number_mode_tests {
use super::*;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct LineNumberConfig {
mode: TextAreaLineNumberMode,
}
#[test]
fn line_number_modes_round_trip_through_traits_and_serde() {
assert_eq!(
"relative".parse::<TextAreaLineNumberMode>(),
Ok(TextAreaLineNumberMode::Relative)
);
assert_eq!(TextAreaLineNumberMode::Absolute.to_string(), "absolute");
assert_eq!(
toml::from_str::<LineNumberConfig>("mode = \"none\"")
.unwrap()
.mode,
TextAreaLineNumberMode::None
);
assert_eq!(
toml::to_string(&LineNumberConfig {
mode: TextAreaLineNumberMode::Relative,
})
.unwrap(),
"mode = \"relative\"\n"
);
}
}
pub struct TextAreaState<P: TextAreaDataProvider = TextAreaProvider> {
pub(crate) core: EditorCore<P>,
pub(crate) scroll_y: u16,
pub(crate) placeholder: Option<String>,
pub(crate) overflow_mode: TextOverflowMode,
pub(crate) h_scroll: u16,
pub(crate) search_query: Option<String>,
pub(crate) active_search_match: Option<TextAreaSearchMatch>,
pub(crate) helix_pending: Option<crate::textarea::actions::selection::helix::HelixPending>,
pub(crate) helix_last_find: Option<crate::textarea::actions::selection::helix::HelixFind>,
pub(crate) vim_pending: Option<crate::textarea::actions::selection::vim::VimPending>,
pub(crate) vim_last_find: Option<crate::textarea::actions::selection::vim::VimFind>,
#[cfg(feature = "gui")]
pub(crate) line_number_mode: TextAreaLineNumberMode,
#[cfg(feature = "gui")]
pub(crate) wrap_indent_cols: u16,
#[cfg(feature = "gui")]
pub(crate) viewport_width: u16,
#[cfg(feature = "gui")]
pub(crate) viewport_height: u16,
#[cfg(feature = "gui")]
pub(crate) edited_this_frame: bool,
#[cfg(feature = "commandline")]
pub(crate) commandline: Option<TextAreaCommandLineState>,
}
impl<P: TextAreaDataProvider + Default> Default for TextAreaState<P> {
fn default() -> Self {
Self {
core: EditorCore::new(P::default()),
scroll_y: 0,
placeholder: None,
overflow_mode: TextOverflowMode::Indicator { ch: '$' },
h_scroll: 0,
search_query: None,
active_search_match: None,
helix_pending: None,
helix_last_find: None,
vim_pending: None,
vim_last_find: None,
#[cfg(feature = "gui")]
line_number_mode: TextAreaLineNumberMode::None,
#[cfg(feature = "gui")]
wrap_indent_cols: 0,
#[cfg(feature = "gui")]
viewport_width: 80,
#[cfg(feature = "gui")]
viewport_height: 10,
#[cfg(feature = "gui")]
edited_this_frame: false,
#[cfg(feature = "commandline")]
commandline: None,
}
}
}
impl<P: TextAreaDataProvider> std::ops::Deref for TextAreaState<P> {
type Target = EditorCore<P>;
fn deref(&self) -> &Self::Target {
&self.core
}
}
impl<P: TextAreaDataProvider> std::ops::DerefMut for TextAreaState<P> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.core
}
}
impl<P: TextAreaDataProvider> TextAreaState<P> {
pub fn with_provider(provider: P) -> Self {
Self {
core: EditorCore::new(provider),
scroll_y: 0,
placeholder: None,
overflow_mode: TextOverflowMode::Indicator { ch: '$' },
h_scroll: 0,
search_query: None,
active_search_match: None,
helix_pending: None,
helix_last_find: None,
vim_pending: None,
vim_last_find: None,
#[cfg(feature = "gui")]
line_number_mode: TextAreaLineNumberMode::None,
#[cfg(feature = "gui")]
wrap_indent_cols: 0,
#[cfg(feature = "gui")]
viewport_width: 80,
#[cfg(feature = "gui")]
viewport_height: 10,
#[cfg(feature = "gui")]
edited_this_frame: false,
#[cfg(feature = "commandline")]
commandline: None,
}
}
pub fn from_text<S: Into<String>>(text: S) -> Self {
Self::with_provider(P::from_text(text.into()))
}
#[cfg(feature = "commandline")]
pub fn use_default_commandline(&mut self) {
self.commandline = Some(TextAreaCommandLineState::default());
}
#[cfg(feature = "commandline")]
pub fn commandline(&self) -> Option<&TextAreaCommandLineState> {
self.commandline.as_ref()
}
#[cfg(feature = "commandline")]
pub fn commandline_mut(&mut self) -> Option<&mut TextAreaCommandLineState> {
self.commandline.as_mut()
}
#[cfg(all(feature = "gui", feature = "commandline"))]
pub fn commandline_textarea_area(&self, area: Rect) -> Rect {
if self.commandline.is_some() {
Rect {
height: area.height.saturating_sub(1),
..area
}
} else {
area
}
}
#[cfg(all(feature = "gui", feature = "commandline"))]
pub fn cursor_with_commandline(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) {
self.cursor(area, block)
}
#[cfg(all(
feature = "commandline",
feature = "keybindings",
feature = "crossterm"
))]
pub fn handle_key_event_with_commandline(
&mut self,
key: crossterm::event::KeyEvent,
) -> KeyEventOutcome {
self.handle_key_event(key)
}
#[cfg(all(feature = "commandline", feature = "keybindings"))]
pub(crate) fn apply_default_commandline_submit(&mut self, submit: CommandLineSubmit) {
let is_helix = self.core.keybinding_paradigm().is_helix();
match submit {
CommandLineSubmit::SearchForward(query) => {
self.set_search_query(query);
self.find_next();
if is_helix {
self.select_active_search_match_helix();
}
}
CommandLineSubmit::SearchBackward(query) => {
self.set_search_query(query);
self.find_previous();
if is_helix {
self.select_active_search_match_helix();
}
}
CommandLineSubmit::Command(command) => self.apply_default_commandline_command(&command),
}
}
#[cfg(all(feature = "commandline", feature = "keybindings"))]
pub(crate) fn apply_default_commandline_command(&mut self, command: &str) {
let Some(commandline) = &self.commandline else {
return;
};
let Ok(invocation) = commandline.commands.dispatch(command) else {
return;
};
match invocation.command.name.as_str() {
#[cfg(feature = "gui")]
"set-number" => self.show_absolute_line_numbers(),
#[cfg(feature = "gui")]
"set-relative-number" => self.show_relative_line_numbers(),
#[cfg(feature = "gui")]
"set-no-number" => self.hide_line_numbers(),
"no-highlight" => self.clear_search(),
_ => {}
}
}
pub fn text(&self) -> String {
self.core.data_provider().to_text()
}
pub fn set_text<S: Into<String>>(&mut self, text: S) {
self.core.data_provider_mut().set_text(text.into());
self.core.ui_state.current_field = 0;
self.core.set_cursor_raw(0);
self.active_search_match = None;
}
pub fn set_placeholder<S: Into<String>>(&mut self, s: S) {
self.placeholder = Some(s.into());
}
pub fn use_overflow_indicator(&mut self, ch: char) {
self.overflow_mode = TextOverflowMode::Indicator { ch };
}
pub fn use_wrap(&mut self) {
self.overflow_mode = TextOverflowMode::Wrap;
}
pub fn set_search_query<S: Into<String>>(&mut self, query: S) {
let query = query.into();
if query.is_empty() {
self.clear_search();
} else {
self.search_query = Some(query);
self.active_search_match = None;
}
}
pub fn clear_search(&mut self) {
self.search_query = None;
self.active_search_match = None;
}
pub fn search_query(&self) -> Option<&str> {
self.search_query.as_deref()
}
pub fn active_search_match(&self) -> Option<TextAreaSearchMatch> {
let m = self.active_search_match?;
let query = self.search_query.as_deref()?;
let provider = self.core.data_provider();
if m.line >= provider.line_count() || m.end <= m.start {
return None;
}
let actual: String = provider
.field_value(m.line)
.chars()
.skip(m.start)
.take(m.end - m.start)
.collect();
(actual == query).then_some(m)
}
pub fn search_matches_in_line(&self, line_idx: usize) -> Vec<TextAreaSearchMatch> {
let Some(query) = self.search_query.as_deref() else {
return Vec::new();
};
if query.is_empty() || line_idx >= self.core.data_provider().line_count() {
return Vec::new();
}
let line = self.core.data_provider().field_value(line_idx);
line.match_indices(query)
.map(|(byte_start, matched)| {
let start = line[..byte_start].chars().count();
let end = start + matched.chars().count();
TextAreaSearchMatch {
line: line_idx,
start,
end,
}
})
.collect()
}
pub fn search_matches(&self) -> Vec<TextAreaSearchMatch> {
let mut matches = Vec::new();
let total = self.core.data_provider().line_count();
for line_idx in 0..total {
matches.extend(self.search_matches_in_line(line_idx));
}
matches
}
pub fn find_next(&mut self) -> bool {
let matches = self.search_matches();
if matches.is_empty() {
self.active_search_match = None;
return false;
}
let cursor = (self.current_field(), self.cursor_position());
let target = matches
.iter()
.copied()
.find(|m| (m.line, m.start) > cursor)
.unwrap_or(matches[0]);
self.move_to_search_match(target)
}
pub fn find_previous(&mut self) -> bool {
let matches = self.search_matches();
if matches.is_empty() {
self.active_search_match = None;
return false;
}
let cursor = (self.current_field(), self.cursor_position());
let target = matches
.iter()
.rev()
.copied()
.find(|m| (m.line, m.start) < cursor)
.unwrap_or(*matches.last().unwrap());
self.move_to_search_match(target)
}
fn move_to_search_match(&mut self, target: TextAreaSearchMatch) -> bool {
let moved = self.core.transition_to_field(target.line).is_ok();
self.core
.set_cursor_for_mode(target.start, self.core.current_text().chars().count());
self.active_search_match = Some(target);
moved
}
pub fn set_wrap_indent_cols(&mut self, cols: u16) {
#[cfg(feature = "gui")]
{
self.wrap_indent_cols = cols;
}
}
#[cfg(feature = "gui")]
pub fn set_line_number_mode(&mut self, mode: TextAreaLineNumberMode) {
self.line_number_mode = mode;
self.h_scroll = 0;
}
#[cfg(feature = "gui")]
pub fn line_number_mode(&self) -> TextAreaLineNumberMode {
self.line_number_mode
}
#[cfg(feature = "gui")]
pub fn show_absolute_line_numbers(&mut self) {
self.set_line_number_mode(TextAreaLineNumberMode::Absolute);
}
#[cfg(feature = "gui")]
pub fn show_relative_line_numbers(&mut self) {
self.set_line_number_mode(TextAreaLineNumberMode::Relative);
}
#[cfg(feature = "gui")]
pub fn hide_line_numbers(&mut self) {
self.set_line_number_mode(TextAreaLineNumberMode::None);
}
#[cfg(feature = "gui")]
pub(crate) fn line_number_gutter_width(&self) -> u16 {
if matches!(self.line_number_mode, TextAreaLineNumberMode::None) {
return 0;
}
let line_count = self.core.data_provider().line_count().max(1);
let digits = line_count.ilog10() as u16 + 1;
digits.saturating_add(1)
}
#[cfg(feature = "gui")]
pub(crate) fn content_area(&self, inner: Rect) -> Rect {
let gutter_width = self.line_number_gutter_width().min(inner.width);
Rect {
x: inner.x.saturating_add(gutter_width),
y: inner.y,
width: inner.width.saturating_sub(gutter_width),
height: inner.height,
}
}
#[cfg(feature = "gui")]
pub(crate) fn line_number_prefix(&self, line_idx: usize, first_visual_row: bool) -> String {
let width = self.line_number_gutter_width() as usize;
if width == 0 {
return String::new();
}
if !first_visual_row {
return " ".repeat(width);
}
let number = match self.line_number_mode {
TextAreaLineNumberMode::None => return String::new(),
TextAreaLineNumberMode::Absolute => line_idx.saturating_add(1),
TextAreaLineNumberMode::Relative if line_idx == self.current_field() => {
line_idx.saturating_add(1)
}
TextAreaLineNumberMode::Relative => line_idx.abs_diff(self.current_field()),
};
format!("{number:>digits$} ", digits = width.saturating_sub(1))
}
#[cfg(feature = "gui")]
fn visual_rows_before_line_and_intra_indented(&self, width: u16, line_idx: usize) -> u16 {
let provider = self.core.data_provider();
let mut acc: u16 = 0;
let indent = self.wrap_indent_cols;
for i in 0..line_idx {
let s = provider.field_value(i);
acc = acc.saturating_add(count_wrapped_rows_indented(s, width, indent));
}
acc
}
#[cfg(feature = "gui")]
pub fn cursor(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) {
#[cfg(feature = "commandline")]
{
if let Some(commandline) = &self.commandline {
if commandline.state.is_active() {
return commandline.state.cursor(area);
}
}
}
#[cfg(feature = "commandline")]
let area = self.commandline_textarea_area(area);
let inner = if let Some(b) = block {
b.inner(area)
} else {
area
};
let inner = self.content_area(inner);
let line_idx = self.current_field();
match self.overflow_mode {
TextOverflowMode::Wrap => {
let width = inner.width;
let y_top = inner.y;
let indent = self.wrap_indent_cols;
if width == 0 {
let prefix = self.visual_rows_before_line_and_intra_indented(1, line_idx);
let y = y_top.saturating_add(prefix.saturating_sub(self.scroll_y));
return (inner.x, y);
}
let prefix_rows = self.visual_rows_before_line_and_intra_indented(width, line_idx);
let current_line = self.current_text();
let col_chars = self.display_cursor_position();
let (subrow, x_cols) =
wrapped_rows_to_cursor_indented(current_line, width, indent, col_chars);
let caret_vis_row = prefix_rows.saturating_add(subrow);
let y = y_top.saturating_add(caret_vis_row.saturating_sub(self.scroll_y));
let x = inner.x.saturating_add(x_cols);
(x, y)
}
TextOverflowMode::Indicator { .. } => {
let y = inner.y + (line_idx as u16).saturating_sub(self.scroll_y);
let current_line = self.current_text();
let col = self.display_cursor_position();
let mut x_cols: u16 = 0;
let mut total_cols: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if i < col {
x_cols = x_cols.saturating_add(w);
}
total_cols = total_cols.saturating_add(w);
}
let left_cols = if self.h_scroll > 0 { 1 } else { 0 };
let mut x_off_visible = x_cols
.saturating_sub(self.h_scroll)
.saturating_add(left_cols);
let limit = inner
.width
.saturating_sub(1 + effective_right_pad(x_cols, total_cols));
if x_off_visible > limit {
x_off_visible = limit;
}
let x = inner.x.saturating_add(x_off_visible);
(x, y)
}
}
}
#[cfg(feature = "gui")]
pub(crate) fn ensure_visible(&mut self, area: Rect, block: Option<&Block<'_>>) {
let inner = if let Some(b) = block {
b.inner(area)
} else {
area
};
let inner = self.content_area(inner);
if inner.height == 0 {
return;
}
self.viewport_width = inner.width;
self.viewport_height = inner.height;
match self.overflow_mode {
TextOverflowMode::Indicator { .. } => {
let line_idx_u16 = self.current_field() as u16;
if line_idx_u16 < self.scroll_y {
self.scroll_y = line_idx_u16;
} else if line_idx_u16 >= self.scroll_y + inner.height {
self.scroll_y = line_idx_u16.saturating_sub(inner.height - 1);
}
let width = inner.width;
if width == 0 {
return;
}
let current_line = self.current_text();
let mut total_cols: u16 = 0;
for ch in current_line.chars() {
total_cols =
total_cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
if total_cols <= width {
self.h_scroll = 0;
return;
}
let col = self.display_cursor_position();
let mut cursor_cols: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
if i >= col {
break;
}
cursor_cols =
cursor_cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, total_cols, width);
if target_h > self.h_scroll {
self.h_scroll = target_h;
} else if cursor_cols < self.h_scroll {
self.h_scroll = cursor_cols;
}
}
TextOverflowMode::Wrap => {
let width = inner.width;
if width == 0 {
self.h_scroll = 0;
return;
}
let indent = self.wrap_indent_cols;
let line_idx = self.current_field();
let prefix_rows = self.visual_rows_before_line_and_intra_indented(width, line_idx);
let current_line = self.current_text();
let col = self.display_cursor_position();
let (subrow, _x_cols) =
wrapped_rows_to_cursor_indented(current_line, width, indent, col);
let caret_vis_row = prefix_rows.saturating_add(subrow);
let top = self.scroll_y;
let height = inner.height;
if caret_vis_row < top {
self.scroll_y = caret_vis_row;
} else {
let bottom = top.saturating_add(height.saturating_sub(1));
if caret_vis_row > bottom {
let shift = caret_vis_row.saturating_sub(bottom);
self.scroll_y = top.saturating_add(shift);
}
}
self.h_scroll = 0;
}
}
}
#[cfg(feature = "gui")]
pub(crate) fn move_visual_line_helix(&mut self, down: bool, count: usize) -> bool {
if !matches!(self.overflow_mode, TextOverflowMode::Wrap) || self.viewport_width == 0 {
let mut moved = false;
for _ in 0..count.max(1) {
moved |= if down {
self.move_down()
} else {
self.move_up()
};
}
return moved;
}
let mut moved = false;
let target_col = self
.core
.ui_state
.ideal_cursor_column
.min(u16::MAX as usize) as u16;
for _ in 0..count.max(1) {
moved |= self.move_visual_line_once(down, target_col);
}
self.core.ui_state.ideal_cursor_column = target_col as usize;
moved
}
#[cfg(feature = "gui")]
fn move_visual_line_once(&mut self, down: bool, target_col: u16) -> bool {
let width = self.viewport_width;
let indent = self.wrap_indent_cols;
let line_idx = self.current_field();
let current_line = self.current_text().to_string();
let cursor = self.display_cursor_position();
let (subrow, _cursor_col) =
wrapped_rows_to_cursor_indented(¤t_line, width, indent, cursor);
let current_ranges = wrap_segment_ranges(¤t_line, width, indent);
let target_line = if down {
if (subrow as usize) + 1 < current_ranges.len() {
Some(line_idx)
} else if line_idx + 1 < self.core.data_provider().field_count() {
Some(line_idx + 1)
} else {
None
}
} else if subrow > 0 {
Some(line_idx)
} else if line_idx > 0 {
Some(line_idx - 1)
} else {
None
};
let Some(target_line) = target_line else {
return false;
};
let target_text = self
.core
.data_provider()
.field_value(target_line)
.to_string();
let target_ranges = wrap_segment_ranges(&target_text, width, indent);
let target_subrow = if target_line == line_idx {
if down {
subrow as usize + 1
} else {
subrow.saturating_sub(1) as usize
}
} else if down {
0
} else {
target_ranges.len().saturating_sub(1)
};
let (start, end) = target_ranges
.get(target_subrow)
.copied()
.unwrap_or((0, target_text.chars().count()));
let prefix_cols = if target_subrow == 0 {
0
} else {
continuation_prefix_width(width, indent)
};
let target_pos =
char_index_for_visual_col(&target_text, start, end, prefix_cols, target_col);
if target_line != line_idx && self.core.transition_to_field(target_line).is_err() {
return false;
}
let char_len = self.current_text().chars().count();
self.core.set_cursor_for_mode(target_pos, char_len);
self.core.ui_state.ideal_cursor_column = target_col as usize;
true
}
#[cfg(feature = "gui")]
pub(crate) fn take_edited_flag(&mut self) -> bool {
let v = self.edited_this_frame;
self.edited_this_frame = false;
v
}
}
impl<P: TextAreaDataProvider> TextAreaState<P> {
pub fn core(&self) -> &EditorCore<P> {
&self.core
}
pub fn core_mut(&mut self) -> &mut EditorCore<P> {
&mut self.core
}
pub fn editor(&self) -> &EditorCore<P> {
&self.core
}
pub fn editor_mut(&mut self) -> &mut EditorCore<P> {
&mut self.core
}
pub fn current_field(&self) -> usize {
self.core.current_field()
}
pub fn cursor_position(&self) -> usize {
self.core.cursor_position()
}
pub fn current_text(&self) -> &str {
self.core.current_text()
}
pub fn mode(&self) -> AppMode {
self.core.mode()
}
pub fn ui_state(&self) -> &EditorState {
self.core.ui_state()
}
pub fn selection_state(&self) -> &SelectionState {
self.core.selection_state()
}
pub(crate) fn selection_endpoints(&self) -> ((usize, usize), (usize, usize)) {
self.core.selection_endpoints()
}
pub fn data_provider(&self) -> &P {
self.core.data_provider()
}
pub fn data_provider_mut(&mut self) -> &mut P {
self.core.data_provider_mut()
}
pub fn move_left(&mut self) -> anyhow::Result<()> {
self.core.move_left()
}
pub fn move_right(&mut self) -> anyhow::Result<()> {
self.core.move_right()
}
pub fn move_up(&mut self) -> bool {
self.core.move_up()
}
pub fn move_down(&mut self) -> bool {
self.core.move_down()
}
pub fn move_first_line(&mut self) -> anyhow::Result<()> {
self.core.move_first_line()
}
pub fn move_last_line(&mut self) -> anyhow::Result<()> {
self.core.move_last_line()
}
pub fn move_line_start(&mut self) {
self.core.move_line_start();
}
pub fn move_line_end(&mut self) {
self.core.move_line_end();
}
pub fn set_cursor_position(&mut self, position: usize) {
self.core.set_cursor_position(position);
}
pub(crate) fn transition_to_field(&mut self, new_field: usize) -> anyhow::Result<()> {
self.core.transition_to_field(new_field)
}
pub fn enter_edit_mode(&mut self) {
self.core.enter_edit_mode();
}
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
self.core.exit_edit_mode()
}
pub fn set_mode(&mut self, mode: AppMode) {
self.core.set_mode(mode);
}
pub fn is_highlight_mode(&self) -> bool {
self.core.is_highlight_mode()
}
pub fn enter_highlight_mode(&mut self) {
self.core.enter_highlight_mode();
}
pub fn enter_highlight_line_mode(&mut self) {
self.core.enter_highlight_line_mode();
}
pub fn exit_highlight_mode(&mut self) {
self.core.exit_highlight_mode();
}
#[cfg(feature = "keybindings")]
pub(crate) fn enter_edit_mode_vim(&mut self) {
self.core.enter_edit_mode_vim();
}
#[cfg(feature = "keybindings")]
pub(crate) fn set_mode_vim(&mut self, mode: AppMode) {
self.core.set_mode_vim(mode);
}
#[cfg(feature = "keybindings")]
pub(crate) fn exit_highlight_mode_vim(&mut self) {
self.core.exit_highlight_mode_vim();
}
#[cfg(feature = "keybindings")]
pub(crate) fn enter_edit_mode_helix(&mut self) {
self.core.enter_edit_mode_helix();
}
#[cfg(feature = "keybindings")]
pub(crate) fn ensure_helix_primary_selection(&mut self) {
self.core.ensure_helix_primary_selection();
}
#[cfg(feature = "keybindings")]
pub(crate) fn collapse_selection_to_cursor(&mut self) {
self.core.collapse_selection_to_cursor();
}
#[cfg(feature = "keybindings")]
pub(crate) fn exit_highlight_mode_emacs(&mut self) {
self.core.exit_highlight_mode_emacs();
}
pub fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
self.core.insert_char(ch)
}
pub fn insert_text(&mut self, text: &str) -> anyhow::Result<()> {
self.core.insert_text(text)
}
pub fn delete_backward(&mut self) -> anyhow::Result<()> {
self.core.delete_backward()
}
pub fn delete_forward(&mut self) -> anyhow::Result<()> {
self.core.delete_forward()
}
#[cfg(feature = "keybindings")]
pub fn use_keybinding_preset(
&mut self,
preset: crate::keybindings::BuiltinCanvasKeybindingPreset,
) {
self.core.set_keybinding_preset(preset);
}
#[cfg(feature = "keybindings")]
pub fn set_keybindings(&mut self, keybindings: CanvasKeyBindings) {
self.core.set_keybindings(keybindings);
}
pub fn move_word_next(&mut self) {
self.core.move_word_next();
}
pub fn move_word_prev(&mut self) {
self.core.move_word_prev();
}
pub fn move_word_end(&mut self) {
self.core.move_word_end();
}
pub fn move_word_end_prev(&mut self) {
self.core.move_word_end_prev();
}
pub fn move_big_word_next(&mut self) {
self.core.move_big_word_next();
}
pub fn move_big_word_prev(&mut self) {
self.core.move_big_word_prev();
}
pub fn move_big_word_end(&mut self) {
self.core.move_big_word_end();
}
pub fn move_big_word_end_prev(&mut self) {
self.core.move_big_word_end_prev();
}
pub fn enter_append_mode(&mut self) {
self.core.enter_append_mode();
}
#[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String {
self.core.current_display_text()
}
#[cfg(not(feature = "validation"))]
pub fn current_display_text(&self) -> String {
self.core.current_text().to_string()
}
pub fn display_cursor_position(&self) -> usize {
self.core.display_cursor_position()
}
#[cfg(feature = "cursor-style")]
pub fn update_cursor_style(&self) -> io::Result<()> {
CursorManager::update_for_mode(self.core.mode())
}
#[cfg(not(feature = "cursor-style"))]
pub fn update_cursor_style(&self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(feature = "validation")]
impl<P: TextAreaDataProvider> TextAreaState<P> {
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.core.set_validation_enabled(enabled);
}
pub fn is_validation_enabled(&self) -> bool {
self.core.is_validation_enabled()
}
pub fn set_field_validation(
&mut self,
field_index: usize,
config: crate::validation::ValidationConfig,
) {
self.core.set_field_validation(field_index, config);
}
pub fn remove_field_validation(&mut self, field_index: usize) {
self.core.remove_field_validation(field_index);
}
pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult {
self.core.validate_current_field()
}
pub fn validate_field(
&mut self,
field_index: usize,
) -> Option<crate::validation::ValidationResult> {
self.core.validate_field(field_index)
}
pub fn clear_validation_results(&mut self) {
self.core.clear_validation_results();
}
pub fn validation_summary(&self) -> crate::validation::ValidationSummary {
self.core.validation_summary()
}
pub fn can_switch_fields(&self) -> bool {
self.core.can_switch_fields()
}
pub fn field_switch_block_reason(&self) -> Option<String> {
self.core.field_switch_block_reason()
}
pub fn last_switch_block(&self) -> Option<&str> {
self.core.last_switch_block()
}
pub fn current_limits_status_text(&self) -> Option<String> {
self.core.current_limits_status_text()
}
pub fn current_formatter_warning(&self) -> Option<String> {
self.core.current_formatter_warning()
}
pub fn external_validation_of(
&self,
field_index: usize,
) -> crate::validation::ExternalValidationState {
self.core.external_validation_of(field_index)
}
pub fn clear_all_external_validation(&mut self) {
self.core.clear_all_external_validation();
}
pub fn clear_external_validation(&mut self, field_index: usize) {
self.core.clear_external_validation(field_index);
}
pub fn set_external_validation(
&mut self,
field_index: usize,
state: crate::validation::ExternalValidationState,
) {
self.core.set_external_validation(field_index, state);
}
pub fn set_external_validation_callback<F>(&mut self, callback: F)
where
F: FnMut(usize, &str) -> crate::validation::ExternalValidationState + Send + Sync + 'static,
{
self.core.set_external_validation_callback(callback);
}
}
#[cfg(feature = "computed")]
impl<P: TextAreaDataProvider> TextAreaState<P> {
pub fn register_computed_provider<C>(&mut self, provider: &C)
where
C: crate::computed::ComputedProvider,
{
self.core.register_computed_provider(provider);
}
pub fn set_computed_provider<C>(&mut self, provider: C)
where
C: crate::computed::ComputedProvider,
{
self.core.set_computed_provider(provider);
}
pub fn recompute_fields<C>(&mut self, provider: &mut C, field_indices: &[usize])
where
C: crate::computed::ComputedProvider,
{
self.core.recompute_fields(provider, field_indices);
}
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
where
C: crate::computed::ComputedProvider,
{
self.core.recompute_all_fields(provider);
}
pub fn on_field_changed<C>(&mut self, provider: &mut C, changed_field: usize)
where
C: crate::computed::ComputedProvider,
{
self.core.on_field_changed(provider, changed_field);
}
pub fn effective_field_value(&self, field_index: usize) -> String {
self.core.effective_field_value(field_index)
}
}
impl<P: TextAreaDataProvider> TextAreaState<P> {
pub fn undo(&mut self) -> bool {
self.core.undo()
}
pub fn redo(&mut self) -> bool {
self.core.redo()
}
pub fn can_undo(&self) -> bool {
self.core.can_undo()
}
pub fn can_redo(&self) -> bool {
self.core.can_redo()
}
pub fn clear_history(&mut self) {
self.core.clear_history();
}
pub fn set_history_limit(&mut self, limit: usize) {
self.core.set_history_limit(limit);
}
}
#[cfg(feature = "suggestions")]
impl<P: TextAreaDataProvider> TextAreaState<P> {
pub fn open_suggestions(&mut self, field_index: usize) {
self.core.open_suggestions(field_index);
}
pub fn check_suggestion_trigger(&mut self) {
self.core.check_suggestion_trigger();
}
pub fn trigger_suggestions(&mut self) -> Option<(usize, String)> {
self.core.trigger_suggestions()
}
pub fn apply_suggestions(&mut self, items: Vec<crate::SuggestionItem>) {
self.core.apply_suggestions(items);
}
pub fn update_suggestions(&mut self, items: Vec<crate::SuggestionItem>) {
self.core.update_suggestions(items);
}
pub fn dismiss_suggestions(&mut self) {
self.core.dismiss_suggestions();
}
pub fn cancel_suggestions(&mut self) {
self.core.cancel_suggestions();
}
pub fn suggestions_next(&mut self) {
self.core.suggestions_next();
}
pub fn suggestions_prev(&mut self) {
self.core.suggestions_prev();
}
pub fn apply_suggestion(&mut self) -> Option<String> {
self.core.apply_suggestion()
}
pub fn is_suggestions_active(&self) -> bool {
self.core.is_suggestions_active()
}
pub fn is_suggestions_loading(&self) -> bool {
self.core.ui_state().is_suggestions_loading()
}
pub fn dropdown_suggestions(&self) -> &[crate::SuggestionItem] {
self.core.suggestions()
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "crossterm")]
use super::TextAreaEventOutcome;
use super::TextAreaState;
use crate::textarea::provider::TextAreaProvider;
#[test]
fn paste_splits_lines() {
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("ab");
textarea.enter_edit_mode();
textarea.set_cursor_position(2);
textarea.paste("c\r\nd\nef");
assert_eq!(textarea.text(), "abc\nd\nef");
}
#[cfg(feature = "crossterm")]
#[test]
fn input_reports_handled_and_ignored() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("");
let out = textarea.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(out, TextAreaEventOutcome::Handled);
assert_eq!(textarea.text(), "x");
let out = textarea.input(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(out, TextAreaEventOutcome::Handled);
assert_eq!(textarea.text(), "x\n");
let out = textarea.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(out, TextAreaEventOutcome::Ignored);
}
#[test]
fn undo_textarea_newline_and_typing() {
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("");
textarea.enter_edit_mode();
let _ = textarea.insert_text("ab"); textarea.insert_newline(); let _ = textarea.insert_text("cd"); assert_eq!(textarea.text(), "ab\ncd");
assert!(textarea.undo()); assert_eq!(textarea.text(), "ab\n");
assert!(textarea.undo()); assert_eq!(textarea.text(), "ab");
assert!(textarea.undo()); assert_eq!(textarea.text(), "");
assert!(!textarea.undo());
assert!(textarea.redo()); assert_eq!(textarea.text(), "ab");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_open_line_below_inserts_textarea_line() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\n\ntwo");
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 0);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_open_line_above_inserts_textarea_line() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let _ = textarea.move_down();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::SHIFT));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\n\ntwo");
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 0);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vscode_move_and_duplicate_line_ops() {
use crate::keybindings::CanvasKeyBindings;
let mut ta = TextAreaState::<TextAreaProvider>::from_text("a\nb\nc");
ta.set_keybindings(CanvasKeyBindings::vscode_defaults());
let _ = ta.move_down(); assert_eq!(ta.current_field(), 1);
ta.move_line_up(1);
assert_eq!(ta.text(), "b\na\nc");
assert_eq!(ta.current_field(), 0);
ta.move_line_down(1);
assert_eq!(ta.text(), "a\nb\nc");
assert_eq!(ta.current_field(), 1);
ta.duplicate_line_down(1);
assert_eq!(ta.text(), "a\nb\nb\nc");
assert_eq!(ta.current_field(), 2);
ta.duplicate_line_up(1);
assert_eq!(ta.text(), "a\nb\nb\nb\nc");
assert_eq!(ta.current_field(), 2);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vscode_copy_and_cut_current_line() {
use crate::editor::behavior::YankRegister;
use crate::keybindings::CanvasKeyBindings;
let mut ta = TextAreaState::<TextAreaProvider>::from_text("first\nsecond\nthird");
ta.set_keybindings(CanvasKeyBindings::vscode_defaults());
let _ = ta.move_down();
ta.copy_current_line();
assert_eq!(
ta.core.behavior_state.yank().register(),
Some(&YankRegister::Lines(vec!["second".to_string()]))
);
ta.cut_current_line();
assert_eq!(ta.text(), "first\nthird");
assert_eq!(
ta.core.behavior_state.yank().register(),
Some(&YankRegister::Lines(vec!["second".to_string()]))
);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vscode_shift_select_then_type_replaces() {
use crate::canvas::state::SelectionState;
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut ta = TextAreaState::<TextAreaProvider>::from_text("hello");
ta.set_keybindings(CanvasKeyBindings::vscode_defaults());
ta.enter_edit_mode();
ta.set_cursor_position(0);
for _ in 0..3 {
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
}
assert!(matches!(
ta.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
assert_eq!(ta.cursor_position(), 3);
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(ta.text(), "Xlo");
assert!(matches!(ta.selection_state(), SelectionState::None));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vscode_copy_selection_and_collapse() {
use crate::canvas::state::SelectionState;
use crate::editor::behavior::YankRegister;
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut ta = TextAreaState::<TextAreaProvider>::from_text("hello");
ta.set_keybindings(CanvasKeyBindings::vscode_defaults());
ta.enter_edit_mode();
ta.set_cursor_position(0);
for _ in 0..3 {
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
}
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(
ta.core.behavior_state.yank().register(),
Some(&YankRegister::Text(vec!["hel".to_string()]))
);
assert_eq!(ta.text(), "hello");
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert!(matches!(ta.selection_state(), SelectionState::None));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vscode_backspace_deletes_selection() {
use crate::canvas::state::SelectionState;
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut ta = TextAreaState::<TextAreaProvider>::from_text("hello");
ta.set_keybindings(CanvasKeyBindings::vscode_defaults());
ta.enter_edit_mode();
ta.set_cursor_position(0);
for _ in 0..3 {
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
}
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(ta.text(), "lo");
assert!(matches!(ta.selection_state(), SelectionState::None));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vscode_alt_up_moves_line_through_keymap() {
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut ta = TextAreaState::<TextAreaProvider>::from_text("a\nb\nc");
ta.set_keybindings(CanvasKeyBindings::vscode_defaults());
ta.enter_edit_mode(); let _ = ta.move_down();
let _ = ta.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert_eq!(ta.text(), "b\na\nc");
assert_eq!(ta.current_field(), 0);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_edit_enter_inserts_newline() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.enter_edit_mode();
textarea.set_cursor_position(3);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\n");
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 0);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_edit_backspace_and_delete_join_textarea_lines() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.enter_edit_mode();
let _ = textarea.move_down();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "onetwo");
assert_eq!(textarea.current_field(), 0);
assert_eq!(textarea.cursor_position(), 3);
textarea.set_text("one\ntwo");
textarea.enter_edit_mode();
textarea.set_cursor_position(3);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "onetwo");
assert_eq!(textarea.current_field(), 0);
assert_eq!(textarea.cursor_position(), 3);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_normal_x_and_x_delete_without_entering_edit_mode() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let _ = textarea.move_right();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ac");
assert_eq!(textarea.cursor_position(), 1);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "c");
assert_eq!(textarea.cursor_position(), 0);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_edit_tab_inserts_spaces_in_textarea() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("ab");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.enter_edit_mode();
textarea.set_cursor_position(1);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "a b");
assert_eq!(textarea.cursor_position(), 5);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_keybinding_path_ignores_key_releases() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.enter_edit_mode();
let out = textarea.handle_key_event(KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: crossterm::event::KeyEventState::NONE,
});
assert!(matches!(out, KeyEventOutcome::NotMatched));
assert_eq!(textarea.text(), "");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_line_end_preserves_preferred_column_across_vertical_moves() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abcde\nxy\nabcdef");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 0);
assert_eq!(textarea.cursor_position(), 4);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 1);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 2);
assert_eq!(textarea.cursor_position(), 4);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_capital_i_and_a_enter_insert_at_line_edges() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abcd");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.set_cursor_position(2);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 0);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
let _ = textarea.exit_edit_mode();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 4);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_d_and_c_change_to_line_end() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abcdef");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.set_cursor_position(2);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ab");
assert_eq!(textarea.cursor_position(), 1);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
textarea.set_text("abcdef");
textarea.set_cursor_position(2);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ab");
assert_eq!(textarea.cursor_position(), 2);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_dd_and_cc_delete_or_change_current_line() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let _ = textarea.move_down();
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
KeyEventOutcome::Consumed(None)
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\nthree");
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)),
KeyEventOutcome::Consumed(None)
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\n");
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 0);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_j_joins_line_below() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "onetwo");
assert_eq!(textarea.current_field(), 0);
assert_eq!(textarea.cursor_position(), 3);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_counts_repeat_motion_and_delete_actions() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abcde\none\ntwo\nthree");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)),
KeyEventOutcome::Pending
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 3);
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)),
KeyEventOutcome::Pending
));
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
KeyEventOutcome::Consumed(None)
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "two\nthree");
assert_eq!(textarea.current_field(), 0);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_ctrl_u_and_ctrl_d_move_half_page() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea =
TextAreaState::<TextAreaProvider>::from_text("0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
textarea.viewport_height = 6;
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 3);
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 0);
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)),
KeyEventOutcome::Pending
));
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 6);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_yank_line_and_paste_after_or_before() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let _ = textarea.move_down();
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
KeyEventOutcome::Consumed(None)
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\ntwo\ntwo\nthree");
assert_eq!(textarea.current_field(), 2);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\ntwo\ntwo\ntwo\nthree");
assert_eq!(textarea.current_field(), 2);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_visual_y_yanks_selection_and_exits_highlight_mode() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
assert_eq!(textarea.text(), "one\ntwo\nthree");
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\ntwo\none\ntwo\nthree");
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_visual_d_and_c_act_on_selection() {
use crate::canvas::modes::AppMode;
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let press = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), n));
};
let mut t = TextAreaState::<TextAreaProvider>::from_text("hello");
t.set_keybindings(CanvasKeyBindings::vim_defaults());
press(&mut t, 'v'); press(&mut t, 'l'); press(&mut t, 'l'); press(&mut t, 'd');
assert_eq!(t.text(), "lo");
assert_eq!(t.mode(), AppMode::Nor);
let mut t = TextAreaState::<TextAreaProvider>::from_text("hello");
t.set_keybindings(CanvasKeyBindings::vim_defaults());
press(&mut t, 'v');
press(&mut t, 'l');
press(&mut t, 'l');
press(&mut t, 'c');
assert_eq!(t.text(), "lo");
assert_eq!(t.mode(), AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_find_char_with_repeat_and_reverse() {
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let press = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), n));
};
let mut t = TextAreaState::<TextAreaProvider>::from_text("hello world");
t.set_keybindings(CanvasKeyBindings::vim_defaults());
press(&mut t, 'f');
press(&mut t, 'o'); assert_eq!(t.cursor_position(), 4);
press(&mut t, ';'); assert_eq!(t.cursor_position(), 7);
press(&mut t, ','); assert_eq!(t.cursor_position(), 4);
let mut t = TextAreaState::<TextAreaProvider>::from_text("hello");
t.set_keybindings(CanvasKeyBindings::vim_defaults());
press(&mut t, 't');
press(&mut t, 'l'); assert_eq!(t.cursor_position(), 1);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_replace_char_and_toggle_case() {
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let press = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), n));
};
let mut t = TextAreaState::<TextAreaProvider>::from_text("hello");
t.set_keybindings(CanvasKeyBindings::vim_defaults());
press(&mut t, 'r');
press(&mut t, 'x');
assert_eq!(t.text(), "xello");
assert_eq!(t.cursor_position(), 0);
let mut t = TextAreaState::<TextAreaProvider>::from_text("abc");
t.set_keybindings(CanvasKeyBindings::vim_defaults());
press(&mut t, '~');
assert_eq!(t.text(), "Abc");
assert_eq!(t.cursor_position(), 1);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_operator_behaviour_matches_vim() {
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let cases: &[(&str, &str, &str)] = &[
("hello world", "dw", "world"), ("hello", "dw", ""), ("hello", "dW", ""), ("foo bar", "wdw", "foo "), ("foo.bar", "dw", ".bar"), ("ab\ncd", "dw", "\ncd"), ("hello world", "lldw", "heworld"), ("hello world", "de", " world"), ("hello", "llde", "he"), ("hello world", "d$", ""), ("hello world", "llld0", "lo world"), ("hello", "dl", "ello"), ("hello", "lldh", "hllo"), ("hello", "dh", "hello"), ("hello world", "$db", "hello d"), ("a b c d", "2dw", "c d"), ("a b c d", "d2w", "c d"), ("a b", "d3w", ""), ("one\ntwo", "ddp", "two\none"), ("foo bar baz", "dwP", "foo bar baz"), ("abc", "cc\x1bp", "\nabc"), ("one\ntwo\nthree", "cj\x1bp", "\none\ntwo\nthree"),
("one\ntwo\nthree", "dd", "two\nthree"),
("only", "dd", ""), ("one\ntwo\nthree", "dj", "three"), ("one\ntwo", "jdj", "one\ntwo"), ("one\ntwo", "dk", "one\ntwo"), ("one\ntwo", "jdk", ""), ("a\nb\nc\nd", "jdG", "a"), ("a\nb\nc", "jjdgg", ""), ("hello world", "dfo", " world"), ("hello world", "dto", "o world"), ("hello world", "dfz", "hello world"), ("hello world", "fod;", "hellrld"), ("hello world", "fo;d,", "hellorld"), ("hello", "d\x1b", "hello"), ("hello", "dx", "hello"), ("hello", "x", "ello"),
("hello", "llX", "hllo"),
];
for (start, keys, expected) in cases {
let mut t = TextAreaState::<TextAreaProvider>::from_text(*start);
t.set_keybindings(CanvasKeyBindings::vim_defaults());
for ch in keys.chars() {
let code = if ch == '\x1b' {
KeyCode::Esc
} else {
KeyCode::Char(ch)
};
let _ = t.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(
t.text(),
*expected,
"start={start:?} keys={keys:?} -> got {:?}, want {expected:?}",
t.text()
);
}
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_operator_charwise_motions() {
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let press = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), n));
};
let vim = |s: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(s);
t.set_keybindings(CanvasKeyBindings::vim_defaults());
t
};
let mut t = vim("hello world");
press(&mut t, 'd');
press(&mut t, 'w');
assert_eq!(t.text(), "world");
assert_eq!(t.cursor_position(), 0);
let mut t = vim("hello world");
press(&mut t, 'd');
press(&mut t, 'e');
assert_eq!(t.text(), " world");
let mut t = vim("hello world");
press(&mut t, 'd');
press(&mut t, '$');
assert_eq!(t.text(), "");
let mut t = vim("a b c d");
press(&mut t, '2');
press(&mut t, 'd');
press(&mut t, 'w');
assert_eq!(t.text(), "c d");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_operator_linewise_and_change() {
use crate::canvas::modes::AppMode;
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let press = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), n));
};
let vim = |s: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(s);
t.set_keybindings(CanvasKeyBindings::vim_defaults());
t
};
let mut t = vim("one\ntwo\nthree");
press(&mut t, 'd');
press(&mut t, 'd');
assert_eq!(t.text(), "two\nthree");
let mut t = vim("one\ntwo\nthree");
press(&mut t, 'd');
press(&mut t, 'j');
assert_eq!(t.text(), "three");
let mut t = vim("hello world");
press(&mut t, 'c');
press(&mut t, 'w');
assert_eq!(t.text(), " world");
assert_eq!(t.mode(), AppMode::Ins);
let mut t = vim("abc");
press(&mut t, 'c');
press(&mut t, 'c');
assert_eq!(t.text(), "");
assert_eq!(t.mode(), AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_operator_yank_and_find() {
use crate::keybindings::CanvasKeyBindings;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let press = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), n));
};
let vim = |s: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(s);
t.set_keybindings(CanvasKeyBindings::vim_defaults());
t
};
let mut t = vim("hello world");
press(&mut t, 'y');
press(&mut t, 'w');
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor_position(), 0);
press(&mut t, 'P');
assert_eq!(t.text(), "hello hello world");
let mut t = vim("hello world");
press(&mut t, 'd');
press(&mut t, 'f');
press(&mut t, 'o');
assert_eq!(t.text(), " world");
let mut t = vim("hello world");
press(&mut t, 'd');
press(&mut t, 't');
press(&mut t, 'o');
assert_eq!(t.text(), "o world");
}
#[cfg(all(
feature = "keybindings",
feature = "crossterm",
feature = "commandline"
))]
#[test]
fn helix_active_search_match_invalidated_by_edit() {
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let mut t = TextAreaState::<TextAreaProvider>::from_text("foo bar foo baz foo");
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t.use_default_commandline();
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
k(&mut t, 'e', n); k(&mut t, '*', n); k(&mut t, 'n', n); assert!(t.active_search_match().is_some());
k(&mut t, 'd', n);
assert_eq!(t.text(), "foo bar baz foo");
assert!(t.active_search_match().is_none());
let starts: Vec<usize> = t.search_matches().iter().map(|m| m.start).collect();
assert_eq!(starts, vec![0, 13]);
}
#[cfg(all(
feature = "keybindings",
feature = "crossterm",
feature = "commandline"
))]
#[test]
fn helix_star_then_n_and_shift_n_navigate() {
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let n = KeyModifiers::NONE;
let mut t = TextAreaState::<TextAreaProvider>::from_text("foo bar foo baz foo");
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t.use_default_commandline();
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
k(&mut t, 'e', n);
k(&mut t, '*', n);
assert_eq!(t.search_query(), Some("foo"));
k(&mut t, 'n', n);
assert_eq!(t.cursor_position(), 10);
k(&mut t, 'N', KeyModifiers::SHIFT);
assert_eq!(t.cursor_position(), 2);
k(&mut t, 'N', KeyModifiers::SHIFT);
assert_eq!(t.cursor_position(), 18);
assert_eq!(t.search_matches().len(), 3);
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Esc, n));
assert_eq!(t.search_query(), None);
assert!(t.search_matches().is_empty());
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_wave4_surround_and_insert_deletes() {
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mk = |text: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(text);
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t
};
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
let n = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
let alt = KeyModifiers::ALT;
let mut t = mk("abc");
k(&mut t, '%', n);
k(&mut t, 'm', n);
k(&mut t, 's', n);
k(&mut t, '(', n);
assert_eq!(t.text(), "(abc)");
let mut t = mk("(abc)");
t.set_cursor_position(2);
k(&mut t, 'm', n);
k(&mut t, 'd', n);
k(&mut t, '(', n);
assert_eq!(t.text(), "abc");
let mut t = mk("(abc)");
t.set_cursor_position(2);
k(&mut t, 'm', n);
k(&mut t, 'r', n);
k(&mut t, '(', n);
k(&mut t, '{', n);
assert_eq!(t.text(), "{abc}");
let mut t = mk("foo bar");
k(&mut t, 'A', n); k(&mut t, 'w', ctrl);
assert_eq!(t.text(), "foo ");
let mut t = mk("foo bar");
k(&mut t, 'A', n);
k(&mut t, 'u', ctrl);
assert_eq!(t.text(), "");
let mut t = mk("foo bar");
k(&mut t, 'i', n); k(&mut t, 'd', alt);
assert_eq!(t.text(), "bar");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_wave3_find_till_replace() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mk = |text: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(text);
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t
};
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
let n = KeyModifiers::NONE;
let alt = KeyModifiers::ALT;
let mut t = mk("one two three");
k(&mut t, 'f', n);
k(&mut t, 'o', n);
assert_eq!(t.cursor_position(), 6);
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
let mut t = mk("one two three");
k(&mut t, 't', n);
k(&mut t, 'o', n);
assert_eq!(t.cursor_position(), 5);
let mut t = mk("one two three");
t.set_cursor_position(6);
k(&mut t, 'F', n);
k(&mut t, 'o', n);
assert_eq!(t.cursor_position(), 0);
let mut t = mk("oxoxox");
k(&mut t, 'f', n);
k(&mut t, 'x', n);
assert_eq!(t.cursor_position(), 1);
k(&mut t, '.', alt);
assert_eq!(t.cursor_position(), 3);
let mut t = mk("abcd");
k(&mut t, 'w', n);
k(&mut t, 'r', n);
k(&mut t, 'x', n);
assert_eq!(t.text(), "xxxx");
let mut t = mk("abcd");
k(&mut t, 'r', n);
k(&mut t, 'Z', n);
assert_eq!(t.text(), "Zbcd");
let mut t = mk("abcd");
k(&mut t, 'r', n);
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Esc, n));
assert_eq!(t.text(), "abcd");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_wave2_indent_and_number_ops() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mk = |text: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(text);
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t
};
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
let n = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
let mut t = mk("ab\ncd");
k(&mut t, '%', n);
k(&mut t, '>', n);
assert_eq!(t.text(), " ab\n cd");
k(&mut t, '<', n);
assert_eq!(t.text(), "ab\ncd");
let mut t = mk("x 5 y");
k(&mut t, 'a', ctrl);
assert_eq!(t.text(), "x 6 y");
assert_eq!(t.cursor_position(), 2);
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (0, 2) }
));
let mut t = mk("x 5 y");
k(&mut t, 'x', ctrl);
assert_eq!(t.text(), "x 4 y");
let mut t = mk("v -3 w");
k(&mut t, 'a', ctrl);
assert_eq!(t.text(), "v -2 w");
}
#[cfg(all(
feature = "keybindings",
feature = "crossterm",
feature = "commandline"
))]
#[test]
fn helix_wave1_movement_and_selection_ops() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mk = |text: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(text);
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t
};
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
let n = KeyModifiers::NONE;
let alt = KeyModifiers::ALT;
let mut t = mk("a(bc)d");
t.set_cursor_position(1);
k(&mut t, 'm', n);
k(&mut t, 'm', n);
assert_eq!(t.cursor_position(), 4);
k(&mut t, 'm', n);
k(&mut t, 'm', n);
assert_eq!(t.cursor_position(), 1);
let mut t = mk("bc x bc");
k(&mut t, 'w', n);
k(&mut t, '*', n);
assert_eq!(t.search_query(), Some("bc "));
let mut t = mk("one two");
t.set_cursor_position(4);
k(&mut t, 'b', n);
assert_eq!(t.cursor_position(), 0);
k(&mut t, ':', alt);
assert_eq!(t.cursor_position(), 3);
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
let mut t = mk("ab\ncd");
k(&mut t, 'J', n);
assert_eq!(t.text(), "ab cd");
let mut t = mk("l0\nl1\nl2");
k(&mut t, 'G', n);
assert_eq!(t.current_field(), 2);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_selection_and_case_ops() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mk = |text: &str| {
let mut t = TextAreaState::<TextAreaProvider>::from_text(text);
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
t
};
let k = |t: &mut TextAreaState<TextAreaProvider>, c: char, m: KeyModifiers| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), m));
};
let n = KeyModifiers::NONE;
let alt = KeyModifiers::ALT;
let mut t = mk("ab\ncd");
k(&mut t, '%', n);
assert_eq!((t.current_field(), t.cursor_position()), (1, 1));
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
let mut t = mk("Hello");
k(&mut t, '%', n);
k(&mut t, '~', n);
assert_eq!(t.text(), "hELLO");
let mut t = mk("Hello");
k(&mut t, '%', n);
k(&mut t, '`', n);
assert_eq!(t.text(), "hello");
let mut t = mk("Hello");
k(&mut t, '%', n);
k(&mut t, '`', alt);
assert_eq!(t.text(), "HELLO");
let mut t = mk(" hi");
k(&mut t, '%', n);
k(&mut t, '_', n);
assert_eq!(t.cursor_position(), 3);
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (0, 2) }
));
let mut t = mk(" abc");
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char('g'), n));
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char('s'), n));
assert_eq!(t.cursor_position(), 3);
let mut t = mk("one two three");
k(&mut t, 'w', n); k(&mut t, ';', alt);
assert_eq!(t.cursor_position(), 0);
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (0, 3) }
));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_w_crosses_line_boundary_cleanly() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let key = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
};
let mut t = TextAreaState::<TextAreaProvider>::from_text("ab cd\nef gh");
t.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
key(&mut t, 'w');
assert_eq!((t.current_field(), t.cursor_position()), (0, 2));
key(&mut t, 'w');
assert_eq!((t.current_field(), t.cursor_position()), (0, 4));
key(&mut t, 'w');
assert_eq!((t.current_field(), t.cursor_position()), (1, 2));
assert!(matches!(
t.selection_state(),
SelectionState::Characterwise { anchor: (1, 0) }
));
key(&mut t, 'd');
assert_eq!(t.text(), "ab cd\ngh");
assert_eq!(t.current_field(), 1);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn vim_counted_yank_and_paste_repeat_lines() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree\nfour");
textarea.set_keybindings(CanvasKeyBindings::vim_defaults());
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)),
KeyEventOutcome::Pending
));
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
KeyEventOutcome::Consumed(None)
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert!(matches!(
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)),
KeyEventOutcome::Pending
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\none\ntwo\none\ntwo\ntwo\nthree\nfour");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_d_deletes_primary_selection_without_pending_operator() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.move_right();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ac");
assert_eq!(textarea.cursor_position(), 1);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_delete_in_highlight_mode_returns_to_normal() {
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_v_up_delete_collapses_selection() {
use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(textarea.mode(), AppMode::Nor);
match textarea.selection_state() {
SelectionState::None => {}
SelectionState::Characterwise { anchor } => {
assert_eq!(
*anchor,
(textarea.current_field(), textarea.cursor_position()),
"selection anchor should sit on the cursor"
);
}
other => panic!("expected collapsed selection, got {other:?}"),
}
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_esc_does_not_collapse_selection_in_normal_mode() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(
textarea.selection_state(),
SelectionState::Linewise { .. }
));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
textarea.selection_state(),
SelectionState::Linewise { .. }
));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_append_on_last_character_inserts_after_it() {
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
textarea.move_line_end();
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
assert_eq!(textarea.cursor_position(), 3);
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(textarea.text(), "abcX");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_y_and_p_yank_and_paste_selection() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.move_right();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "onne\ntwo");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_defaults_set_keybindings_installs_helix_paradigm() {
use crate::keybindings::{CanvasKeyBindings, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.set_keybindings(CanvasKeyBindings::helix_defaults());
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ac");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_linewise_yank_pastes_as_lines() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "one\ntwo\none\nthree");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_goto_mode_motions_use_helix_keys() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Pending));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 2);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Pending));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 0);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Pending));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 2);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_word_motions_replace_primary_selection() {
use crate::canvas::state::SelectionState;
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one two three");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 3);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 6);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 3) }
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 7);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 6) }
));
textarea.set_cursor_position(8);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 4);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 7) }
));
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one two three four");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let expected = [(3usize, 0usize), (7, 4), (13, 8), (17, 14)];
for (cursor, anchor) in expected {
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), cursor);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, a) } if *a == anchor
));
}
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_select_mode_word_motions_extend_with_pinned_anchor() {
use crate::canvas::state::SelectionState;
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one two three four");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
for (k, head) in [('w', 3usize), ('w', 7), ('e', 12)] {
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char(k), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
assert_eq!(textarea.cursor_position(), head);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
}
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.cursor_position(), 8);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
}
#[cfg(all(
feature = "keybindings",
feature = "crossterm",
feature = "commandline"
))]
#[test]
fn helix_search_selects_match_and_n_advances() {
use crate::canvas::state::SelectionState;
use crate::keybindings::BuiltinCanvasKeybindingPreset;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc foo xyz foo end");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
textarea.use_default_commandline();
let key = |t: &mut TextAreaState<TextAreaProvider>, c: char| {
let _ = t.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
};
key(&mut textarea, '/');
for c in "foo".chars() {
key(&mut textarea, c);
}
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(textarea.cursor_position(), 6);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 4) }
));
key(&mut textarea, 'n');
assert_eq!(textarea.cursor_position(), 14);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 12) }
));
key(&mut textarea, 'n');
assert_eq!(textarea.cursor_position(), 6);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 4) }
));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_c_enters_insert_after_deleting_selection() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.move_right();
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ac");
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_U_redoes_change() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let _ = textarea.move_right();
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(textarea.text(), "ac");
assert!(textarea.undo());
assert_eq!(textarea.text(), "abc");
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ac");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_x_extends_line_selection() {
use crate::canvas::state::SelectionState;
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo\nthree\nfour");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert!(matches!(
textarea.selection_state(),
SelectionState::Linewise { anchor_field: 0 }
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 1);
assert!(matches!(
textarea.selection_state(),
SelectionState::Linewise { anchor_field: 0 }
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 2);
assert!(matches!(
textarea.selection_state(),
SelectionState::Linewise { anchor_field: 0 }
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.current_field(), 3);
assert!(matches!(
textarea.selection_state(),
SelectionState::Linewise { anchor_field: 0 }
));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (3, _) }
));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn helix_visual_mode_y_yanks_and_returns_to_normal() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one\ntwo");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Helix);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "onone\ntwo");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn emacs_ctrl_space_sets_mark_and_esc_deactivates() {
use crate::canvas::state::SelectionState;
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Emacs);
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
assert!(matches!(
textarea.selection_state(),
SelectionState::Characterwise { anchor: (0, 0) }
));
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
assert!(matches!(textarea.selection_state(), SelectionState::None));
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn emacs_ctrl_w_kills_region() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Emacs);
let _ = textarea.move_right();
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ac");
assert_eq!(textarea.cursor_position(), 1);
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Nor);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn emacs_alt_w_copies_region_without_deleting() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Emacs);
let _ = textarea.move_right();
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
let out = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "abc");
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Sel);
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn emacs_ctrl_y_yanks_killed_text_in_insert_mode() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("abc");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Emacs);
let _ = textarea.move_right();
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(textarea.text(), "ac");
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(textarea.mode(), crate::canvas::modes::AppMode::Ins);
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "abc");
}
#[cfg(all(feature = "keybindings", feature = "crossterm"))]
#[test]
fn emacs_ctrl_y_preserves_newlines_in_insert_mode() {
use crate::keybindings::{BuiltinCanvasKeybindingPreset, KeyEventOutcome};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("ab\ncd");
textarea.use_keybinding_preset(BuiltinCanvasKeybindingPreset::Emacs);
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "d");
let _ = textarea.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let out =
textarea.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
assert!(matches!(out, KeyEventOutcome::Consumed(None)));
assert_eq!(textarea.text(), "ab\ncd");
}
#[test]
fn textarea_search_collects_matches_by_line_and_char_offsets() {
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("alpha beta\nbeta alpha");
textarea.set_search_query("alpha");
assert_eq!(
textarea.search_matches(),
vec![
super::TextAreaSearchMatch {
line: 0,
start: 0,
end: 5,
},
super::TextAreaSearchMatch {
line: 1,
start: 5,
end: 10,
},
]
);
}
#[test]
fn textarea_find_next_and_previous_wrap() {
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one two\nthree two\none");
textarea.set_search_query("two");
assert!(textarea.find_next());
assert_eq!(textarea.current_field(), 0);
assert_eq!(textarea.cursor_position(), 4);
assert!(textarea.find_next());
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 6);
assert!(textarea.find_next());
assert_eq!(textarea.current_field(), 0);
assert_eq!(textarea.cursor_position(), 4);
assert!(textarea.find_previous());
assert_eq!(textarea.current_field(), 1);
assert_eq!(textarea.cursor_position(), 6);
}
#[test]
fn textarea_clear_search_removes_query_and_active_match() {
let mut textarea = TextAreaState::<TextAreaProvider>::from_text("one two");
textarea.set_search_query("two");
assert!(textarea.find_next());
textarea.clear_search();
assert_eq!(textarea.search_query(), None);
assert_eq!(textarea.active_search_match(), None);
assert!(textarea.search_matches().is_empty());
}
}