use gpui::TextAlign;
use gpui::{
Action, App, AppContext, Bounds, ClipboardItem, Context, Edges, Entity, EntityInputHandler,
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point,
Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription,
UTF16Selection, Window, actions, div, point, prelude::FluentBuilder as _, px,
};
use ropey::{Rope, RopeSlice};
use serde::Deserialize;
use std::borrow::Cow;
use std::cell::Cell;
use std::ops::Range;
use std::rc::Rc;
use sum_tree::Bias;
use unicode_segmentation::*;
use super::{
DiagnosticSet, DisplayMap, InputContextMenuCapabilities, InputEditorStyle,
InputHighlighterFactory, MASK_CHAR, MaskPattern, NativeMenu, NumberStep, WrappingIndent,
blink_cursor::BlinkCursor,
change::Change,
cursor::{CursorSelection, Selections},
element::{EditorScrollbar, EditorScrollbarSnapshot, TextElement},
kind::InputModeKind,
mask_pattern::normalize_number_input,
mode::LayoutMode,
undo_manager::{EditIntent, UndoManager},
};
use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp};
use crate::input::blink_cursor::CURSOR_WIDTH;
use crate::input::movement::MoveDirection;
use crate::input::{
InputExtras as _, Position, RopeExt as _, element::RIGHT_MARGIN, layout::LastLayout,
};
use crate::{AutoScroll, StepAction};
pub(crate) enum ScrollPadding {
Minimal,
SurroundingLines,
}
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = input, no_json)]
pub struct Enter {
pub secondary: bool,
pub shift: bool,
}
impl Enter {
pub fn is_primary(action: &dyn Action) -> bool {
action.partial_eq(&Enter {
secondary: false,
shift: false,
}) || action.partial_eq(&Enter {
secondary: false,
shift: true,
})
}
}
actions!(
input,
[
Backspace,
Delete,
DeleteToBeginningOfLine,
DeleteToEndOfLine,
DeleteToPreviousWordStart,
DeleteToNextWordEnd,
Indent,
Outdent,
IndentInline,
OutdentInline,
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
MovePageUp,
MovePageDown,
AddCursorAbove,
AddCursorBelow,
SelectAll,
SelectToStartOfLine,
SelectToEndOfLine,
SelectToStart,
SelectToEnd,
SelectToPreviousWordStart,
SelectToNextWordEnd,
ShowCharacterPalette,
Copy,
Cut,
Paste,
Undo,
Redo,
MoveToStartOfLine,
MoveToEndOfLine,
MoveToStart,
MoveToEnd,
MoveToPreviousWord,
MoveToNextWord,
Escape,
ToggleCodeActions,
Search,
Replace,
GoToDefinition,
]
);
#[derive(Clone)]
pub enum InputEvent {
Change,
PressEnter { secondary: bool, shift: bool },
Focus,
Blur,
}
pub(super) const CONTEXT: &str = "Input";
pub(crate) fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
KeyBinding::new("shift-backspace", Backspace, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-backspace", Backspace, Some(CONTEXT)),
KeyBinding::new("delete", Delete, Some(CONTEXT)),
KeyBinding::new("shift-delete", Delete, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-delete", DeleteToEndOfLine, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("alt-backspace", DeleteToPreviousWordStart, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-backspace", DeleteToPreviousWordStart, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("alt-delete", DeleteToNextWordEnd, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)),
KeyBinding::new(
"enter",
Enter {
secondary: false,
shift: false,
},
Some(CONTEXT),
),
KeyBinding::new(
"shift-enter",
Enter {
secondary: false,
shift: true,
},
Some(CONTEXT),
),
KeyBinding::new(
"secondary-enter",
Enter {
secondary: true,
shift: false,
},
Some(CONTEXT),
),
KeyBinding::new("escape", Escape, Some(CONTEXT)),
KeyBinding::new("up", MoveUp, Some(CONTEXT)),
KeyBinding::new("down", MoveDown, Some(CONTEXT)),
KeyBinding::new("left", MoveLeft, Some(CONTEXT)),
KeyBinding::new("right", MoveRight, Some(CONTEXT)),
KeyBinding::new("pageup", MovePageUp, Some(CONTEXT)),
KeyBinding::new("pagedown", MovePageDown, Some(CONTEXT)),
KeyBinding::new("tab", IndentInline, Some(CONTEXT)),
KeyBinding::new("shift-tab", OutdentInline, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-]", Indent, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-]", Indent, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-[", Outdent, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-[", Outdent, Some(CONTEXT)),
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)),
KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
KeyBinding::new("shift-alt-left", SelectLeft, Some(CONTEXT)),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
KeyBinding::new("shift-alt-right", SelectRight, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-alt-up", AddCursorAbove, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-alt-down", AddCursorBelow, Some(CONTEXT)),
#[cfg(target_os = "windows")]
KeyBinding::new("ctrl-alt-up", AddCursorAbove, Some(CONTEXT)),
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
KeyBinding::new("shift-alt-up", AddCursorAbove, Some(CONTEXT)),
#[cfg(target_os = "windows")]
KeyBinding::new("ctrl-alt-down", AddCursorBelow, Some(CONTEXT)),
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
KeyBinding::new("shift-alt-down", AddCursorBelow, Some(CONTEXT)),
KeyBinding::new("home", MoveHome, Some(CONTEXT)),
KeyBinding::new("end", MoveEnd, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-home", MoveToStart, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-end", MoveToEnd, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-home", SelectToStart, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-end", SelectToEnd, Some(CONTEXT)),
KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)),
KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-shift-e", SelectToEndOfLine, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("shift-cmd-left", SelectToStartOfLine, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("shift-cmd-right", SelectToEndOfLine, Some(CONTEXT)),
#[cfg(any(target_os = "macos", target_os = "linux"))]
KeyBinding::new("alt-shift-left", SelectToPreviousWordStart, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-left", SelectToPreviousWordStart, Some(CONTEXT)),
#[cfg(any(target_os = "macos", target_os = "linux"))]
KeyBinding::new("alt-shift-right", SelectToNextWordEnd, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-shift-right", SelectToNextWordEnd, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-c", Copy, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-x", Cut, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-v", Paste, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-a", MoveHome, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-left", MoveHome, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-e", MoveEnd, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-right", MoveEnd, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-z", Undo, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-shift-z", Redo, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-up", MoveToStart, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-down", MoveToEnd, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("alt-left", MoveToPreviousWord, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("alt-right", MoveToNextWord, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-left", MoveToPreviousWord, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-right", MoveToNextWord, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-shift-up", SelectToStart, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-shift-down", SelectToEnd, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-f", Search, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-f", Search, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-shift-f", Replace, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-h", Replace, Some(CONTEXT)),
]);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ColumnarPoint {
offset: usize,
columns_past_line_end: usize,
}
impl ColumnarPoint {
pub(super) fn new(offset: usize, columns_past_line_end: usize) -> Self {
Self {
offset,
columns_past_line_end,
}
}
}
pub struct InputBaseState<M: InputModeKind> {
pub(crate) extras: M::Extras,
pub(super) focus_handle: FocusHandle,
pub(super) mode: LayoutMode,
pub(super) text: Rope,
pub(super) display_map: DisplayMap,
pub(super) undo_manager: UndoManager,
pub(super) search_session: super::SearchSession,
pub(super) search_activation_revision: u64,
pub(super) searchable: bool,
pub(super) replaceable: bool,
pub(super) soft_wrap: bool,
pub(super) wrapping_indent: WrappingIndent,
pub(super) scroll_beyond_last_line: Option<usize>,
pub(super) cursor_surrounding_lines: Option<usize>,
pub(super) blink_cursor: Entity<BlinkCursor>,
pub(super) loading: bool,
pub(super) selections: Selections,
pub(super) selected_word_range: Option<CursorSelection>,
pub(super) ime_marked_range: Option<CursorSelection>,
pub(super) last_layout: Option<LastLayout>,
pub(super) last_cursor: Option<usize>,
pub(super) input_bounds: Bounds<Pixels>,
pub(super) last_bounds: Option<Bounds<Pixels>>,
pub(super) last_selected_range: Option<CursorSelection>,
pub(super) selecting: bool,
pub(super) column_select_start: Option<ColumnarPoint>,
pub(crate) disabled: bool,
pub(crate) readonly: bool,
pub(crate) text_align: TextAlign,
pub(super) masked: bool,
pub(super) clean_on_escape: bool,
pub(super) submit_on_enter: bool,
pub(super) show_whitespaces: bool,
pub(crate) cursor_line_end_affinity: bool,
pub(super) pattern: Option<regex::Regex>,
pub(super) validate: Option<Box<dyn Fn(&str, &mut App) -> bool + 'static>>,
pub(crate) number_step: Option<NumberStep>,
pub(crate) number_min: Option<f64>,
pub(crate) number_max: Option<f64>,
pub(crate) scroll_handle: ScrollHandle,
pub(crate) deferred_scroll_offset: Option<Point<Pixels>>,
pub(crate) scroll_size: gpui::Size<Pixels>,
pub(super) editor_scrollbar_snapshot: Cell<Option<EditorScrollbarSnapshot>>,
pub(super) editor_paddings: Edges<Pixels>,
pub(super) editor_style: InputEditorStyle,
projected_editor_style: InputEditorStyle,
pub(crate) mask_pattern: MaskPattern,
pub(super) mask_pattern_set: bool,
pub(super) placeholder: SharedString,
pub(super) diagnostic_popover: Option<Rc<crate::input::DiagnosticEntry>>,
context_menu_handler: Option<
Rc<dyn Fn(NativeMenu, InputContextMenuCapabilities, Point<Pixels>, &mut Window, &mut App)>,
>,
pending_context_menu: Option<(Point<Pixels>, usize)>,
pub(super) enable_context_menu: bool,
pub(super) completion_inserting: bool,
pub(super) overlay_action_handler: Option<
Rc<
dyn Fn(
super::InputOverlayKind,
Box<dyn Action>,
&mut Window,
&mut Context<InputBaseState<M>>,
) -> bool,
>,
>,
_pending_update: bool,
pub(super) silent_replace_text: bool,
pub(super) emit_events: bool,
_subscriptions: Vec<Subscription>,
pub(super) auto_scroll: AutoScroll,
}
#[derive(Clone)]
pub struct InputPresentation {
focus_handle: FocusHandle,
disabled: bool,
readonly: bool,
loading: bool,
masked: bool,
multi_line: bool,
code_editor: bool,
text_align: TextAlign,
placeholder: SharedString,
mask_placeholder: Option<String>,
}
impl InputPresentation {
pub fn focus_handle(&self) -> &FocusHandle {
&self.focus_handle
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn is_readonly(&self) -> bool {
self.readonly
}
pub fn is_editable(&self) -> bool {
!self.disabled && !self.readonly
}
pub fn is_loading(&self) -> bool {
self.loading
}
pub fn is_masked(&self) -> bool {
self.masked
}
pub fn is_multi_line(&self) -> bool {
self.multi_line
}
pub fn is_code_editor(&self) -> bool {
self.code_editor
}
pub fn text_align(&self) -> TextAlign {
self.text_align
}
pub fn placeholder(&self) -> &SharedString {
&self.placeholder
}
pub fn mask_placeholder(&self) -> Option<&str> {
self.mask_placeholder.as_deref()
}
}
impl<M: InputModeKind> EventEmitter<InputEvent> for InputBaseState<M> {}
impl<M: InputModeKind> InputBaseState<M> {
#[doc(hidden)]
pub fn cursor_layout(&self) -> Option<(Bounds<Pixels>, Pixels)> {
let layout = self.last_layout.as_ref()?;
Some((layout.cursor_bounds?, layout.line_height))
}
pub fn input_bounds(&self) -> Bounds<Pixels> {
self.input_bounds
}
pub fn text_bounds(&self) -> Option<Bounds<Pixels>> {
self.last_bounds
}
pub fn diagnostic_popover(&self) -> Option<Rc<crate::input::DiagnosticEntry>> {
self.diagnostic_popover.clone()
}
pub fn presentation(&self) -> InputPresentation {
InputPresentation {
focus_handle: self.focus_handle.clone(),
disabled: self.disabled,
readonly: self.readonly,
loading: self.loading,
masked: self.masked,
multi_line: self.is_multi_line(),
code_editor: self.is_code_editor(),
text_align: self.text_align,
placeholder: self.placeholder.clone(),
mask_placeholder: self.mask_pattern.placeholder(),
}
}
#[inline]
pub(crate) fn shows_scrollbar(&self) -> bool {
self.is_multi_line()
}
pub fn is_multi_line(&self) -> bool {
M::MULTI_LINE
}
#[inline]
pub fn is_single_line(&self) -> bool {
!M::MULTI_LINE
}
#[inline]
pub fn is_code_editor(&self) -> bool {
M::CODE_EDITOR
}
pub fn is_copyable(&self) -> bool {
self.selections.iter().any(|sel| !sel.is_empty()) && !self.masked
}
pub fn context_menu_capabilities(&self) -> InputContextMenuCapabilities {
let (go_to_definition, code_actions) = self.extras.context_menu_capabilities();
InputContextMenuCapabilities::new()
.disabled(self.disabled)
.readonly(self.readonly)
.code_editor(self.is_code_editor())
.selection(!self.active_selection().is_empty())
.masked(self.masked)
.go_to_definition(go_to_definition)
.code_actions(code_actions)
}
pub fn set_text_align(&mut self, text_align: TextAlign, cx: &mut Context<Self>) {
if !self.is_single_line() || self.text_align == text_align {
return;
}
self.text_align = text_align;
cx.notify();
}
pub fn toggle_masked(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.masked = !self.masked;
cx.notify();
}
pub fn on_context_menu(
&mut self,
handler: Rc<
dyn Fn(NativeMenu, InputContextMenuCapabilities, Point<Pixels>, &mut Window, &mut App),
>,
) {
self.context_menu_handler = Some(handler);
}
fn new_in_mode(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle().tab_stop(true);
let blink_cursor = cx.new(|_| BlinkCursor::new());
let undo_manager = UndoManager::new();
let _subscriptions = vec![
cx.intercept_keystrokes({
let focus_handle = focus_handle.clone();
let blink_cursor = blink_cursor.downgrade();
move |_, window, cx| {
if focus_handle.is_focused(window) {
_ = blink_cursor.update(cx, |cursor, cx| cursor.pause(cx));
}
}
}),
cx.observe(&blink_cursor, |_, _, cx| cx.notify()),
cx.observe_window_activation(window, |input, window, cx| {
if window.is_window_active() {
let focus_handle = input.focus_handle.clone();
if focus_handle.is_focused(window) {
input.blink_cursor.update(cx, |blink_cursor, cx| {
blink_cursor.start(cx);
});
}
}
}),
cx.on_focus(&focus_handle, window, Self::on_focus),
cx.on_blur(&focus_handle, window, Self::on_blur),
];
let text_style = window.text_style();
Self {
extras: M::Extras::default(),
focus_handle: focus_handle.clone(),
text: "".into(),
display_map: DisplayMap::new(text_style.font(), window.rem_size(), None),
search_session: super::SearchSession::default(),
search_activation_revision: 0,
searchable: false,
replaceable: true,
soft_wrap: true,
wrapping_indent: WrappingIndent::default(),
scroll_beyond_last_line: None,
cursor_surrounding_lines: None,
blink_cursor,
undo_manager,
selections: Selections::default(),
selected_word_range: None,
ime_marked_range: None,
input_bounds: Bounds::default(),
selecting: false,
disabled: false,
readonly: false,
text_align: TextAlign::Left,
masked: false,
clean_on_escape: false,
submit_on_enter: false,
show_whitespaces: false,
loading: false,
pattern: None,
validate: None,
number_step: Some(NumberStep::Fixed(1.)),
number_min: None,
number_max: None,
mode: LayoutMode::default(),
last_layout: None,
last_bounds: None,
last_selected_range: None,
column_select_start: None,
last_cursor: None,
scroll_handle: ScrollHandle::new(),
scroll_size: gpui::size(px(0.), px(0.)),
editor_scrollbar_snapshot: Cell::new(None),
editor_paddings: Edges::default(),
deferred_scroll_offset: None,
placeholder: SharedString::default(),
mask_pattern: MaskPattern::default(),
mask_pattern_set: false,
editor_style: InputEditorStyle::default(),
projected_editor_style: InputEditorStyle::default(),
diagnostic_popover: None,
context_menu_handler: None,
pending_context_menu: None,
enable_context_menu: true,
completion_inserting: false,
overlay_action_handler: None,
silent_replace_text: false,
emit_events: true,
_subscriptions,
_pending_update: false,
cursor_line_end_affinity: false,
auto_scroll: AutoScroll::default(),
}
}
pub fn context_menu(mut self, enable: bool) -> Self {
self.enable_context_menu = enable;
self
}
pub fn set_context_menu_enabled(&mut self, enabled: bool) {
self.enable_context_menu = enabled;
}
#[doc(hidden)]
pub fn replaceable(mut self, allow: bool) -> Self {
self.replaceable = allow;
self
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn set_highlighter(
&mut self,
new_language: impl Into<SharedString>,
cx: &mut Context<Self>,
) {
match &mut self.mode {
LayoutMode::CodeEditor {
language,
highlighter,
..
} => {
language.set_name(new_language.into());
*highlighter.borrow_mut() = None;
}
_ => {}
}
self.refresh(cx);
}
fn reset_highlighter(&mut self, cx: &mut Context<Self>) {
match &mut self.mode {
LayoutMode::CodeEditor { highlighter, .. } => {
*highlighter.borrow_mut() = None;
}
_ => {}
}
cx.notify();
}
pub fn set_highlighter_factory(
&mut self,
factory: InputHighlighterFactory,
cx: &mut Context<Self>,
) {
self.mode.set_highlighter_factory(factory);
self._pending_update = true;
cx.notify();
}
pub fn ensure_highlighter_factory(&mut self, factory: InputHighlighterFactory) {
self.mode.ensure_highlighter_factory(factory);
}
pub fn set_editor_style(&mut self, style: InputEditorStyle) {
self.editor_style = style.clone();
self.projected_editor_style = style;
}
#[doc(hidden)]
pub fn set_editor_paddings(&mut self, paddings: Edges<Pixels>) {
self.editor_paddings = paddings;
}
pub fn apply_highlighter_fold_candidates(
&mut self,
candidates: Vec<crate::input::FoldRange>,
cx: &mut Context<Self>,
) {
if self.mode.is_folding() {
self.display_map.set_fold_candidates(candidates);
}
cx.notify();
}
#[inline]
pub fn diagnostics(&self) -> Option<&DiagnosticSet> {
self.mode.diagnostics()
}
#[inline]
pub fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticSet> {
self.mode.diagnostics_mut()
}
pub fn set_placeholder(
&mut self,
placeholder: impl Into<SharedString>,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.placeholder = placeholder.into();
cx.notify();
}
pub(super) fn line_and_position_for_offset(
&self,
offset: usize,
) -> (usize, usize, Option<Point<Pixels>>) {
let Some(last_layout) = &self.last_layout else {
return (0, 0, None);
};
let line_height = last_layout.line_height;
let mut y_offset = last_layout.visible_top;
for (vi, line) in last_layout.lines.iter().enumerate() {
let prev_lines_offset = last_layout.visible_line_byte_offsets[vi];
let local_offset = offset.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(local_offset, last_layout, false) {
let sub_line_index = (pos.y / line_height) as usize;
let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset);
return (vi, sub_line_index, Some(adjusted_pos));
}
y_offset += line.size(line_height).height;
}
(0, 0, None)
}
pub fn set_value(
&mut self,
value: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.set_ignoring(true);
self.emit_events = false;
self.replace_text(value, window, cx);
self.undo_manager.set_ignoring(false);
self.emit_events = true;
self.reset_selection();
self.reset_lsp_state();
self.reset_scroll_to_start();
self.undo_manager.clear();
cx.notify();
}
pub fn replace_all(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.replace_text(text, window, cx);
self.reset_selection();
self.reset_lsp_state();
self.reset_scroll_to_start();
cx.notify();
}
fn with_edits_allowed(&mut self, f: impl FnOnce(&mut Self)) {
let (was_disabled, was_readonly) = (self.disabled, self.readonly);
(self.disabled, self.readonly) = (false, false);
f(self);
(self.disabled, self.readonly) = (was_disabled, was_readonly);
}
pub fn insert(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let text: SharedString = text.into();
self.with_edits_allowed(|this| {
this.undo_manager.set_pending_intent(EditIntent::Atomic);
let range_utf16 = this.range_to_utf16(&(this.cursor()..this.cursor()));
this.replace_text_in_range_silent(Some(range_utf16), &text, window, cx);
let end = this.active_selection().end;
this.set_cursor_to(end);
});
}
pub fn replace(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let text: SharedString = text.into();
self.with_edits_allowed(|this| {
this.undo_manager.set_pending_intent(EditIntent::Atomic);
this.replace_text_in_range_silent(None, &text, window, cx);
let end = this.active_selection().end;
this.set_cursor_to(end);
});
}
fn replace_text(
&mut self,
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let text: SharedString = text.into();
self.with_edits_allowed(|this| {
this.undo_manager.set_pending_intent(EditIntent::Atomic);
let range = 0..this.text.chars().map(|c| c.len_utf16()).sum();
this.replace_text_in_range_silent(Some(range), &text, window, cx);
this.reset_highlighter(cx);
});
}
fn reset_selection(&mut self) {
self.selections.remove_all_but_active();
if self.is_single_line() {
let end = self.text.len();
self.set_cursor_to(end);
} else {
self.active_selection_mut().clear();
}
}
fn reset_lsp_state(&mut self) {
if self.is_code_editor() {
self._pending_update = true;
M::reset_language_features(self);
}
}
fn reset_scroll_to_start(&mut self) {
self.scroll_handle.set_offset(point(px(0.), px(0.)));
if self.is_single_line() {
self.deferred_scroll_offset = Some(point(px(0.), px(0.)));
}
}
#[allow(unused)]
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
if self.disabled == disabled {
return;
}
self.disabled = disabled;
cx.notify();
}
#[allow(unused)]
pub(crate) fn readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
pub fn set_readonly(&mut self, readonly: bool, cx: &mut Context<Self>) {
if self.readonly == readonly {
return;
}
self.readonly = readonly;
if readonly {
self.search_session.replace_mode = false;
}
cx.notify();
}
pub fn is_editable(&self) -> bool {
!self.disabled && !self.readonly
}
pub fn clean_on_escape(mut self) -> Self {
self.clean_on_escape = true;
self
}
pub fn set_clean_on_escape(&mut self, clean: bool) {
self.clean_on_escape = clean;
}
#[doc(hidden)]
pub fn submit_on_enter(mut self, submit: bool) -> Self {
self.submit_on_enter = submit;
self
}
pub fn set_submit_on_enter(&mut self, submit: bool, cx: &mut Context<Self>) {
self.submit_on_enter = submit;
cx.notify();
}
#[doc(hidden)]
pub fn show_whitespaces(mut self, show: bool) -> Self {
self.show_whitespaces = show;
self
}
pub fn set_show_whitespaces(&mut self, show: bool, _: &mut Window, cx: &mut Context<Self>) {
self.show_whitespaces = show;
cx.notify();
}
pub fn scroll_beyond_last_line(mut self, rows: Option<usize>) -> Self {
self.scroll_beyond_last_line = rows;
self
}
pub fn set_scroll_beyond_last_line(
&mut self,
rows: Option<usize>,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.scroll_beyond_last_line == rows {
return;
}
self.scroll_beyond_last_line = rows;
cx.notify();
}
pub fn cursor_surrounding_lines(mut self, lines: Option<usize>) -> Self {
self.cursor_surrounding_lines = lines;
self
}
pub fn set_cursor_surrounding_lines(
&mut self,
lines: Option<usize>,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.cursor_surrounding_lines == lines {
return;
}
self.cursor_surrounding_lines = lines;
cx.notify();
}
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
let text: SharedString = value.into();
self.text = Rope::from(self.normalize_input(&text).as_ref());
if let Some(diagnostics) = self.mode.diagnostics_mut() {
diagnostics.reset(&self.text)
}
self._pending_update = true;
self
}
pub fn value(&self) -> SharedString {
SharedString::new(self.text.to_string())
}
pub fn selected_value(&self) -> SharedString {
SharedString::new(self.selected_text().to_string())
}
pub fn unmask_value(&self) -> SharedString {
self.mask_pattern.unmask(&self.text.to_string()).into()
}
#[doc(hidden)]
pub fn prepare(&mut self, _: &mut Window, _: &mut Context<Self>) {}
pub fn text(&self) -> &Rope {
&self.text
}
pub fn cursor_position(&self) -> Position {
let offset = self.cursor();
self.text.offset_to_position(offset)
}
pub fn set_cursor_position(
&mut self,
position: impl Into<Position>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let position: Position = position.into();
let offset = self.text.position_to_offset(&position);
self.move_to(offset, None, cx);
self.update_preferred_column();
self.focus(window, cx);
}
pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
self.focus_handle.focus(window, cx);
self.blink_cursor.update(cx, |cursor, cx| {
cursor.start(cx);
});
}
pub fn refresh(&mut self, cx: &mut Context<Self>) {
self._pending_update = true;
cx.notify();
}
pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(|s, sel| s.previous_boundary(sel.cursor_offset()), cx);
}
pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_all_cursors_to(|s, sel| s.next_boundary(sel.cursor_offset()), cx);
}
pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
if self.is_single_line() {
return;
}
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(
|s, sel| {
let offset = s
.start_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel))
.saturating_sub(1);
s.previous_boundary(offset)
},
cx,
);
}
pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
if self.is_single_line() {
return;
}
self.undo_manager.break_transaction_coalescing();
let len = self.text.len();
self.select_all_cursors_to(
|s, sel| {
let offset = (s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel))
+ 1)
.min(len);
s.next_boundary(offset)
},
cx,
);
}
pub(super) fn on_action_select_all(
&mut self,
_: &SelectAll,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_all(window, cx);
}
pub(super) fn select_to_start(
&mut self,
_: &SelectToStart,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(|_, _| 0, cx);
}
pub(super) fn select_to_end(
&mut self,
_: &SelectToEnd,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
let end = self.text.len();
self.select_all_cursors_to(move |_, _| end, cx);
}
pub(super) fn select_to_start_of_line(
&mut self,
_: &SelectToStartOfLine,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(
|s, sel| s.start_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)),
cx,
);
}
pub(super) fn select_to_end_of_line(
&mut self,
_: &SelectToEndOfLine,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(
|s, sel| s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)),
cx,
);
self.cursor_line_end_affinity = true;
}
pub(super) fn select_to_previous_word(
&mut self,
_: &SelectToPreviousWordStart,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(
|s, sel| s.previous_start_of_word_at(sel.cursor_offset()),
cx,
);
}
pub(super) fn select_to_next_word(
&mut self,
_: &SelectToNextWordEnd,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
self.select_all_cursors_to(|s, sel| s.next_end_of_word_at(sel.cursor_offset()), cx);
}
pub(super) fn previous_start_of_word_at(&self, offset: usize) -> usize {
if self.masked {
return 0;
}
let offset = self.offset_from_utf16(self.offset_to_utf16(offset));
let left_part = self.text.slice(0..offset).to_string();
UnicodeSegmentation::split_word_bound_indices(left_part.as_str())
.rfind(|(_, s)| !s.trim_start().is_empty())
.map(|(i, _)| i)
.unwrap_or(0)
}
pub(super) fn next_end_of_word_at(&self, offset: usize) -> usize {
if self.masked {
return self.text.len();
}
let offset = self.offset_from_utf16(self.offset_to_utf16(offset));
let right_part = self.text.slice(offset..self.text.len()).to_string();
UnicodeSegmentation::split_word_bound_indices(right_part.as_str())
.find(|(_, s)| !s.trim_start().is_empty())
.map(|(i, s)| offset + i + s.len())
.unwrap_or(self.text.len())
}
pub(super) fn start_of_line_at(&self, offset: usize, line_end_affinity: bool) -> usize {
if self.is_single_line() {
return 0;
}
let row = self.text.offset_to_point(offset).row;
let logical_start = self.text.line_start_offset(row);
if self.soft_wrap && self.is_code_editor() {
let wrap_point = self
.display_map
.offset_to_wrap_display_point_with_affinity(offset, line_end_affinity);
if let Some(line) = self.display_map.line(row)
&& let Some(range) = line.wrapped_lines.get(wrap_point.local_row)
{
let visual_start = logical_start + range.start;
if offset != visual_start {
return visual_start;
}
}
}
logical_start
}
pub(super) fn end_of_line_at(&self, offset: usize, line_end_affinity: bool) -> usize {
if self.is_single_line() {
return self.text.len();
}
let row = self.text.offset_to_point(offset).row;
let logical_start = self.text.line_start_offset(row);
let logical_end = self.text.line_end_offset(row);
if self.soft_wrap && self.is_code_editor() {
let wrap_point = self
.display_map
.offset_to_wrap_display_point_with_affinity(offset, line_end_affinity);
if let Some(line) = self.display_map.line(row)
&& let Some(range) = line.wrapped_lines.get(wrap_point.local_row)
{
let visual_end = logical_start + range.end;
if offset != visual_end {
return visual_end;
}
}
}
logical_end
}
pub(super) fn indent_of_next_line(&mut self) -> String {
self.indent_of_next_line_at(self.cursor())
}
pub(super) fn indent_of_next_line_at(&mut self, offset: usize) -> String {
if self.is_single_line() {
return "".into();
}
let mut current_indent = String::new();
let mut next_indent = String::new();
let line_end_affinity = self.line_end_affinity_at(offset);
let current_line_start_pos = self.start_of_line_at(offset, line_end_affinity);
let next_line_start_pos = self.end_of_line_at(offset, line_end_affinity);
for c in self.text.slice(current_line_start_pos..).chars() {
if !c.is_whitespace() {
break;
}
if c == '\n' || c == '\r' {
break;
}
current_indent.push(c);
}
for c in self.text.slice(next_line_start_pos..).chars() {
if !c.is_whitespace() {
break;
}
if c == '\n' || c == '\r' {
break;
}
next_indent.push(c);
}
if next_indent.len() > current_indent.len() {
return next_indent;
}
if let Some(rules) = self.mode.language_config() {
if self.mode.is_smart_indent()
&& M::editing_syntax_context(self, offset) == crate::input::SyntaxContext::Code
{
let line_before: String = self
.text
.slice(current_line_start_pos..offset)
.chars()
.collect();
if rules.opens_indent(line_before.trim_end()) {
let tab = self.mode.tab_size();
if tab.hard_tabs {
current_indent.push('\t');
} else {
for _ in 0..tab.tab_size {
current_indent.push(' ');
}
}
} else {
let line_after = self.text.slice(offset..next_line_start_pos).to_string();
if rules.closes_indent(&line_after) {
let tab = self.mode.tab_size();
if current_indent.ends_with('\t') {
current_indent.pop();
} else {
for _ in 0..tab.tab_size {
if !current_indent.ends_with(' ') {
break;
}
current_indent.pop();
}
}
}
}
}
}
current_indent
}
fn delete_selections(
&mut self,
silent: bool,
collapsed_intent: EditIntent,
mut collapsed_target: impl FnMut(&mut Self, usize) -> Range<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.is_editable() {
return;
}
let cursors: Vec<CursorSelection> = self.selections.iter().copied().collect();
let intent = if cursors.iter().all(|sel| sel.is_empty()) {
collapsed_intent
} else {
EditIntent::Atomic
};
let mut new_selections: Vec<CursorSelection> = Vec::with_capacity(cursors.len());
for sel in &cursors {
let range = if sel.is_empty() {
collapsed_target(self, sel.cursor_offset())
} else {
sel.start..sel.end
};
let (start, end) = (range.start.min(range.end), range.start.max(range.end));
let mut selection = *sel;
selection.start = start;
selection.end = end;
new_selections.push(selection);
}
self.undo_manager.begin_transaction_with(intent);
self.undo_manager
.record_selections(cursors.clone(), cursors.clone());
self.selections.replace_all(new_selections);
self.undo_manager.set_pending_intent(intent);
let was_silent = self.silent_replace_text;
self.silent_replace_text = silent;
self.replace_text_in_range(None, "", window, cx);
self.silent_replace_text = was_silent;
self.undo_manager
.record_selections(cursors, self.selections.iter().copied().collect());
self.undo_manager.commit_transaction();
self.pause_blink_cursor(cx);
}
fn auto_close_target(&self, range: &Range<usize>, text: &str) -> Option<(usize, SharedString)> {
if self.silent_replace_text
|| self.ime_marked_range.is_some()
|| !self.selections.is_single()
|| !self.active_selection().is_empty()
|| !range.is_empty()
|| range.start != self.cursor()
|| text.chars().count() != 1
|| !self.mode.is_auto_close()
{
return None;
}
let rules = self.mode.language_config()?;
if self
.text
.chars_at(range.start)
.next()
.is_some_and(|c| !c.is_whitespace() && !rules.auto_close_before.contains(c))
{
return None;
}
for (open, close, not_in) in rules.closing_pairs() {
let Some(prefix) = open.strip_suffix(text) else {
continue;
};
if !self.text_before_matches(range.start, prefix) {
continue;
}
let start = range.start - prefix.len();
if open == close
&& (self.is_escaped_at(start)
|| self
.text
.chars_at(start)
.reversed()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_'))
{
continue;
}
if not_in.contains(&M::editing_syntax_context(self, start)) {
continue;
}
return Some((open.len(), close.into()));
}
None
}
fn text_before_matches(&self, offset: usize, text: &str) -> bool {
offset >= text.len()
&& self.text.is_char_boundary(offset - text.len())
&& self.text.slice(offset - text.len()..offset) == text
}
fn text_after_matches(&self, offset: usize, text: &str) -> bool {
offset + text.len() <= self.text.len()
&& self.text.is_char_boundary(offset + text.len())
&& self.text.slice(offset..offset + text.len()) == text
}
fn is_escaped_at(&self, offset: usize) -> bool {
self.text
.chars_at(offset)
.reversed()
.take_while(|c| *c == '\\')
.count()
% 2
== 1
}
fn skip_over_target(&self, new_text: &str) -> Option<usize> {
if !self.mode.is_auto_close() || new_text.chars().count() != 1 {
return None;
}
let rules = self.mode.language_config()?;
let cursor = self.cursor();
for (open, close, not_in) in rules.closing_pairs() {
for (index, _) in close.char_indices() {
if !close[index..].starts_with(new_text)
|| !self.text_before_matches(cursor, &close[..index])
|| !self.text_after_matches(cursor, &close[index..])
{
continue;
}
let context = M::editing_syntax_context(self, cursor);
let generated =
self.mode
.auto_closed_pairs()
.contains_closer(cursor, index, close.len());
if !generated
&& not_in.contains(&context)
&& !(context == crate::input::SyntaxContext::String && open == close)
{
continue;
}
if self.is_escaped_at(cursor - index) {
continue;
}
return Some(cursor + new_text.len());
}
}
None
}
pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.selections.is_single() && self.active_selection().is_empty() && self.cursor() == 0 {
cx.propagate();
return;
}
if self.mode.is_auto_close()
&& self.selections.is_single()
&& self.active_selection().is_empty()
{
let off = self.cursor();
let deletion = self.mode.language_config().and_then(|rules| {
rules.closing_pairs().find_map(|(open, close, not_in)| {
if !self.text_before_matches(off, open) || !self.text_after_matches(off, close)
{
return None;
}
let start = off - open.len();
let generated = self
.mode
.auto_closed_pairs()
.contains(start..off, off..off + close.len());
if self.is_escaped_at(start)
|| (!generated && not_in.contains(&M::editing_syntax_context(self, start)))
{
return None;
}
Some(start..off + close.len())
})
});
if let Some(range) = deletion {
let utf16 = self.range_to_utf16(&range);
self.replace_text_in_range_silent(Some(utf16), "", window, cx);
return;
}
}
self.delete_selections(
false,
EditIntent::Backspace,
|s, offset| s.previous_boundary(offset)..offset,
window,
cx,
);
}
pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
self.delete_selections(
false,
EditIntent::DeleteForward,
|s, offset| offset..s.next_boundary(offset),
window,
cx,
);
}
pub(super) fn delete_to_beginning_of_line(
&mut self,
_: &DeleteToBeginningOfLine,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.delete_selections(
true,
EditIntent::Atomic,
|s, offset| {
let mut start = s.start_of_line_at(offset, s.line_end_affinity_at(offset));
if start == offset {
start = start.saturating_sub(1);
}
start..offset
},
window,
cx,
);
}
pub(super) fn delete_to_end_of_line(
&mut self,
_: &DeleteToEndOfLine,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.delete_selections(
true,
EditIntent::Atomic,
|s, offset| {
let mut end = s.end_of_line_at(offset, s.line_end_affinity_at(offset));
if end == offset {
end = (end + 1).clamp(0, s.text.len());
}
offset..end
},
window,
cx,
);
}
pub(super) fn delete_previous_word(
&mut self,
_: &DeleteToPreviousWordStart,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.delete_selections(
true,
EditIntent::Atomic,
|s, offset| s.previous_start_of_word_at(offset)..offset,
window,
cx,
);
}
pub(super) fn delete_next_word(
&mut self,
_: &DeleteToNextWordEnd,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.delete_selections(
true,
EditIntent::Atomic,
|s, offset| offset..s.next_end_of_word_at(offset),
window,
cx,
);
}
pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context<Self>) {
if M::handle_context_menu_action(self, Box::new(action.clone()), window, cx) {
return;
}
if M::has_inline_completion(self) {
M::clear_inline_completion(self, cx);
}
let insert_newline = self.is_multi_line() && (!self.submit_on_enter || action.shift);
if insert_newline {
if !self.selections.is_single() {
self.selections.merge_overlapping();
let selections: Vec<CursorSelection> = self.selections.iter().copied().collect();
let mut edits: Vec<(Range<usize>, String)> = Vec::with_capacity(selections.len());
for sel in &selections {
let indent = if self.is_code_editor() {
self.indent_of_next_line_at(sel.cursor_offset())
} else {
String::new()
};
edits.push((sel.start..sel.end, format!("\n{}", indent)));
}
edits.sort_by_key(|(range, _)| range.start);
self.replace_text_in_ranges(&edits, window, cx);
self.pause_blink_cursor(cx);
} else {
let mut split = false;
if self.is_code_editor() && self.active_selection().is_empty() {
if let Some(rules) = self.mode.language_config() {
if self.mode.is_smart_indent()
&& M::editing_syntax_context(self, self.cursor())
== crate::input::SyntaxContext::Code
{
let off = self.cursor();
let is_pair = rules.brackets.iter().any(|p| {
!p.open.is_empty()
&& !p.close.is_empty()
&& p.open != p.close
&& self.text_before_matches(off, p.open.as_ref())
&& self.text_after_matches(off, p.close.as_ref())
});
if is_pair {
let line_end_affinity = self.line_end_affinity_at(off);
let line_start = self.start_of_line_at(off, line_end_affinity);
let mut indent = String::new();
for c in self.text.slice(line_start..).chars() {
if !c.is_whitespace() || c == '\n' || c == '\r' {
break;
}
indent.push(c);
}
let mut inner = indent.clone();
if self.mode.is_smart_indent() {
let tab = self.mode.tab_size();
if tab.hard_tabs {
inner.push('\t');
} else {
for _ in 0..tab.tab_size {
inner.push(' ');
}
}
}
let new_line_text = format!("\n{inner}\n{indent}");
self.replace_text_in_range_silent(None, &new_line_text, window, cx);
self.set_cursor_to(off + 1 + inner.len());
self.update_preferred_column();
let cursors = self.selections.iter().copied().collect::<Vec<_>>();
self.undo_manager
.record_selections(cursors.clone(), cursors);
self.pause_blink_cursor(cx);
split = true;
}
}
}
}
if !split {
let indent = if self.is_code_editor() {
self.indent_of_next_line()
} else {
"".to_string()
};
let new_line_text = format!("\n{}", indent);
self.replace_text_in_range_silent(None, &new_line_text, window, cx);
self.pause_blink_cursor(cx);
}
}
} else {
self.undo_manager.break_transaction_coalescing();
cx.propagate();
}
cx.emit(InputEvent::PressEnter {
secondary: action.secondary,
shift: action.shift,
});
}
pub fn clean(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.replace_text("", window, cx);
self.set_selection(0, 0);
self.scroll_to(0, None, cx);
}
pub(super) fn escape(&mut self, action: &Escape, window: &mut Window, cx: &mut Context<Self>) {
if M::handle_context_menu_action(self, Box::new(action.clone()), window, cx) {
return;
}
if !self.selections.is_single() {
self.undo_manager.break_transaction_coalescing();
self.selections.remove_all_but_active();
cx.notify();
return;
}
if M::has_inline_completion(self) {
M::clear_inline_completion(self, cx);
return; }
if self.ime_marked_range.is_some() {
self.unmark_text(window, cx);
}
if self.clean_on_escape {
return self.clean(window, cx);
}
cx.propagate();
}
pub(crate) fn handle_right_click_menu(
&mut self,
position: Point<Pixels>,
offset: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
if crate::GlobalState::is_in_deferred_context(cx) {
return;
}
if !self.active_selection().contains(offset) {
self.move_to(offset, None, cx);
}
if self.is_code_editor() {
M::on_hover_definition(self, offset, window, cx);
}
if let Some(handler) = self.context_menu_handler.clone() {
let capabilities = self.context_menu_capabilities();
cx.defer_in(window, move |_, window, cx| {
handler(NativeMenu::new(), capabilities, position, window, cx);
});
}
}
pub(super) fn add_cursor_above(
&mut self,
_: &AddCursorAbove,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.add_cursor_vertical(-1, cx);
}
pub(super) fn add_cursor_below(
&mut self,
_: &AddCursorBelow,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.add_cursor_vertical(1, cx);
}
fn add_cursor_vertical(&mut self, move_lines: isize, cx: &mut Context<Self>) {
if !self.is_multi_line() {
return;
}
self.pause_blink_cursor(cx);
self.undo_manager.break_transaction_coalescing();
let sources: Vec<(usize, Option<(Pixels, usize)>, bool)> = self
.selections
.iter()
.map(|sel| {
(
sel.cursor_offset(),
sel.column_anchor,
self.line_end_affinity_for(sel),
)
})
.collect();
let mut offsets: std::collections::HashSet<usize> =
sources.iter().map(|(offset, _, _)| *offset).collect();
let mut newest: Option<usize> = None;
for (offset, anchor, line_end_affinity) in sources {
let anchor = anchor.or_else(|| self.preferred_column_for(offset));
let (target, _) = self.vertical_target(offset, anchor, line_end_affinity, move_lines);
if target == offset || offsets.contains(&target) {
continue;
}
offsets.insert(target);
let id = self.selections.generate_id();
let mut cursor = CursorSelection::new(id, target, target);
cursor.column_anchor = anchor;
self.selections.add(cursor);
newest = Some(target);
}
if let Some(newest) = newest {
self.scroll_to(newest, None, cx);
}
cx.notify();
}
pub(super) fn add_cursor_at(&mut self, offset: usize, cx: &mut Context<Self>) {
if !self.is_multi_line() {
return;
}
for sel in self.selections.iter() {
if sel.contains(offset) {
return;
}
if sel.is_collapsed() && sel.cursor_offset() == offset {
return;
}
}
self.undo_manager.break_transaction_coalescing();
let id = self.selections.generate_id();
self.selections
.add(CursorSelection::new(id, offset, offset));
cx.notify();
}
pub(super) fn build_columnar_selection(
&mut self,
start: ColumnarPoint,
end: ColumnarPoint,
cx: &mut Context<Self>,
) {
if !self.is_multi_line() {
return;
}
self.undo_manager.break_transaction_coalescing();
let (start_row, start_col) = self.columnar_row_column(start);
let (end_row, end_col) = self.columnar_row_column(end);
let (start_row, end_row) = (start_row.min(end_row), start_row.max(end_row));
let (start_col, end_col) = (start_col.min(end_col), start_col.max(end_col));
let mut new_selections = Vec::with_capacity(end_row - start_row + 1);
for row in start_row..=end_row {
let sel_start = self
.display_map
.display_row_column_to_offset(row, start_col);
let sel_end = self.display_map.display_row_column_to_offset(row, end_col);
let id = self.selections.generate_id();
let sel_start = self.text.clip_offset(sel_start, Bias::Left);
let sel_end = self.text.clip_offset(sel_end, Bias::Left);
new_selections.push(CursorSelection::new(id, sel_start, sel_end));
}
self.selections.replace_all(new_selections);
cx.notify();
}
fn columnar_row_column(&self, point: ColumnarPoint) -> (usize, usize) {
let display_point = self.display_map.offset_to_wrap_display_point(point.offset);
let row = self
.display_map
.wrap_row_to_display_row(display_point.row)
.unwrap_or_else(|| {
self.display_map
.nearest_visible_display_row(display_point.row)
});
(row, display_point.column + point.columns_past_line_end)
}
pub(super) fn on_mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.undo_manager.break_transaction_coalescing();
crate::global_state::GlobalState::suppress_text_selection(cx);
M::clear_inline_completion(self, cx);
if let Some(ime_marked_range) = &self.ime_marked_range {
if ime_marked_range.len() == 0 {
self.ime_marked_range = None;
}
}
self.selecting = true;
let (offset, line_end_affinity, columns_past_line_end) =
self.resolve_mouse_position(event.position);
if M::on_click(self, event, offset, window, cx) {
return;
}
if event.button == MouseButton::Left && event.click_count >= 3 {
self.select_line(offset, window, cx);
return;
}
if event.button == MouseButton::Left && event.click_count == 2 {
self.select_word(offset, window, cx);
return;
}
if event.button == MouseButton::Right {
if self.enable_context_menu {
if !self.active_selection().contains(offset) {
self.move_to(offset, None, cx);
}
self.pending_context_menu = Some((event.position, offset));
}
return;
}
if self.is_multi_line() && event.button == MouseButton::Left {
if event.modifiers.alt
&& (event.modifiers.shift || (cfg!(target_os = "linux") && event.modifiers.control))
{
self.column_select_start = Some(ColumnarPoint::new(offset, columns_past_line_end));
self.selecting = true;
self.move_to_with_affinity(offset, None, line_end_affinity, cx);
return;
} else if event.modifiers.alt {
self.add_cursor_at(offset, cx);
self.column_select_start = Some(ColumnarPoint::new(offset, columns_past_line_end));
return;
}
}
if event.modifiers.shift {
self.select_to_with_affinity(offset, line_end_affinity, cx);
} else {
self.move_to_with_affinity(offset, None, line_end_affinity, cx)
}
}
pub(super) fn on_mouse_up(
&mut self,
event: &MouseUpEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if event.button == MouseButton::Right {
if let Some((position, offset)) = self.pending_context_menu.take() {
self.handle_right_click_menu(position, offset, window, cx);
}
}
if self.active_selection().is_empty() {
self.active_selection_mut().reversed = false;
}
self.selecting = false;
self.selected_word_range = None;
self.column_select_start = None;
self.auto_scroll.stop();
}
pub(super) fn on_mouse_move(
&mut self,
event: &MouseMoveEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let within_bounds = self
.last_bounds
.as_ref()
.map(|bounds| bounds.contains(&event.position))
.unwrap_or(false);
if !within_bounds {
M::clear_hover_state(self, cx);
return;
}
let (offset, _) = self.index_for_mouse_position(event.position);
M::on_mouse_move(self, offset, event, window, cx);
if self.is_code_editor() {
if let Some(diagnostic) = self
.mode
.diagnostics()
.and_then(|set| set.for_offset(offset))
{
self.diagnostic_popover = Some(Rc::new(diagnostic.clone()));
cx.notify();
} else {
self.diagnostic_popover = None;
}
}
}
pub(super) fn on_scroll_wheel(
&mut self,
event: &ScrollWheelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let line_height = self
.last_layout
.as_ref()
.map(|layout| layout.line_height)
.unwrap_or(window.line_height());
let delta = event.delta.pixel_delta(line_height);
let old_offset = self.scroll_handle.offset();
self.update_scroll_offset(Some(old_offset + delta), cx);
if self.scroll_handle.offset() != old_offset {
cx.stop_propagation();
}
if self.diagnostic_popover.take().is_some() {
cx.notify();
}
}
pub(super) fn update_scroll_offset(
&mut self,
offset: Option<Point<Pixels>>,
cx: &mut Context<Self>,
) {
let mut offset = offset.unwrap_or(self.scroll_handle.offset());
let safe_x_offset = if self.text_align == TextAlign::Left {
px(0.)
} else {
-CURSOR_WIDTH
};
let safe_y_range =
(-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.);
let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width + safe_x_offset)
.min(safe_x_offset)..px(0.);
offset.y = if self.is_single_line() {
px(0.)
} else {
offset.y.clamp(safe_y_range.start, safe_y_range.end)
};
offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end);
if self.scroll_handle.offset() != offset {
self.scroll_handle.set_offset(offset);
cx.notify();
}
}
pub(crate) fn scroll_to(
&mut self,
offset: usize,
direction: Option<MoveDirection>,
cx: &mut Context<Self>,
) {
let padding = if direction.is_some() {
ScrollPadding::SurroundingLines
} else {
ScrollPadding::Minimal
};
self.scroll_to_with_padding(offset, direction, padding, cx);
}
pub(crate) fn scroll_to_with_padding(
&mut self,
offset: usize,
direction: Option<MoveDirection>,
padding: ScrollPadding,
cx: &mut Context<Self>,
) {
let Some(last_layout) = self.last_layout.as_ref() else {
return;
};
let Some(bounds) = self.last_bounds.as_ref() else {
return;
};
let mut scroll_offset = self.scroll_handle.offset();
let was_offset = scroll_offset;
let line_height = last_layout.line_height;
let point = self.text.offset_to_point(offset);
let row = point.row;
let mut row_offset_y = line_height * self.display_map.buffer_line_to_display_row(row);
let safety_margin = match last_layout.text_align {
TextAlign::Left => RIGHT_MARGIN,
TextAlign::Right => px(0.),
TextAlign::Center => CURSOR_WIDTH,
};
if let Some(line) = last_layout
.lines
.get(row.saturating_sub(last_layout.visible_range.start))
{
if let Some(pos) = line.position_for_index(point.column, last_layout, false) {
let bounds_width = bounds.size.width - last_layout.line_number_width;
let col_offset_x = pos.x;
row_offset_y += pos.y;
if col_offset_x - safety_margin < -scroll_offset.x {
scroll_offset.x = -col_offset_x + safety_margin;
} else if col_offset_x + safety_margin > -scroll_offset.x + bounds_width {
scroll_offset.x = -(col_offset_x - bounds_width + safety_margin);
}
}
}
let edge_height =
if matches!(padding, ScrollPadding::SurroundingLines) && self.is_code_editor() {
super::element::cursor_surrounding_padding(
self.mode.is_auto_grow(),
self.cursor_surrounding_lines,
last_layout.visible_range.len(),
line_height,
)
} else {
line_height
};
if row_offset_y - edge_height + line_height < -scroll_offset.y {
scroll_offset.y = -row_offset_y + edge_height - line_height;
} else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height {
scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height);
}
if direction == Some(MoveDirection::Up) {
scroll_offset.y = scroll_offset.y.max(was_offset.y);
} else if direction == Some(MoveDirection::Down) {
scroll_offset.y = scroll_offset.y.min(was_offset.y);
}
let safe_y_min = (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.));
scroll_offset.x = scroll_offset.x.min(px(0.));
scroll_offset.y = scroll_offset.y.clamp(safe_y_min, px(0.));
self.deferred_scroll_offset = Some(scroll_offset);
cx.notify();
}
pub(super) fn show_character_palette(
&mut self,
_: &ShowCharacterPalette,
window: &mut Window,
_: &mut Context<Self>,
) {
window.show_character_palette();
}
fn selected_texts(&self) -> Vec<String> {
let mut selections: Vec<CursorSelection> = self
.selections
.iter()
.copied()
.filter(|sel| !sel.is_empty())
.collect();
selections.sort_by_key(|sel| sel.start);
selections
.iter()
.map(|sel| self.text.slice(*sel).to_string())
.collect()
}
pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if !self.is_copyable() {
return;
}
let texts = self.selected_texts();
cx.write_to_clipboard(ClipboardItem::new_string(texts.join("\n")));
}
pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
if !self.is_copyable() {
return;
}
let texts = self.selected_texts();
cx.write_to_clipboard(ClipboardItem::new_string(texts.join("\n")));
self.undo_manager.set_pending_intent(EditIntent::Atomic);
self.replace_text_in_range_silent(None, "", window, cx);
}
pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if !self.is_editable() {
return;
}
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
let mut new_text = clipboard.text().unwrap_or_default();
self.undo_manager.set_pending_intent(EditIntent::Atomic);
if !self.is_multi_line() {
new_text = new_text.replace('\n', "");
self.replace_text_in_range_silent(None, &new_text, window, cx);
self.scroll_to(self.cursor(), None, cx);
return;
}
if !self.selections.is_single() {
self.selections.merge_overlapping();
}
let lines: Vec<String> = new_text.split('\n').map(|s| s.to_string()).collect();
let count = self.selections.len();
if count > 1 && lines.len() == count {
let mut selections: Vec<CursorSelection> = self.selections.iter().copied().collect();
selections.sort_by_key(|sel| sel.start);
let edits: Vec<(Range<usize>, String)> = selections
.iter()
.zip(lines)
.map(|(sel, line)| (sel.start..sel.end, line))
.collect();
self.replace_text_in_ranges(&edits, window, cx);
} else {
self.replace_text_in_range_silent(None, &new_text, window, cx);
}
self.scroll_to(self.cursor(), None, cx);
}
fn typing_intent(&self, edits: &[(Range<usize>, String)], new_text: &str) -> EditIntent {
if !new_text.is_empty()
&& !new_text.contains(['\n', '\r'])
&& edits.iter().all(|(range, _)| range.is_empty())
{
EditIntent::Typing
} else {
EditIntent::Atomic
}
}
fn collapse_for_intent(
intent: EditIntent,
mut selection: CursorSelection,
range: &Range<usize>,
) -> CursorSelection {
match intent {
EditIntent::Backspace => selection.place_at(range.end, None),
EditIntent::DeleteForward => selection.place_at(range.start, None),
EditIntent::Typing | EditIntent::Atomic => {}
}
selection
}
fn push_history(
&mut self,
text: &Rope,
range: &Range<usize>,
new_text: &str,
requested_intent: Option<EditIntent>,
selection_before: CursorSelection,
selection_after: Option<CursorSelection>,
) -> bool {
if self.undo_manager.is_ignoring() {
return false;
}
let range =
text.clip_offset(range.start, Bias::Left)..text.clip_offset(range.end, Bias::Right);
let old_text = text.slice(range.clone()).to_string();
let new_range = range.start..range.start + new_text.len();
let intent = requested_intent.unwrap_or_else(|| {
if range.is_empty()
&& old_text.is_empty()
&& !new_text.is_empty()
&& !new_text.contains(['\n', '\r'])
{
EditIntent::Typing
} else {
EditIntent::Atomic
}
});
let selection_before = Self::collapse_for_intent(intent, selection_before, &range);
let selection_after =
selection_after.unwrap_or_else(|| (new_range.end..new_range.end).into());
let open_transaction = self.undo_manager.has_open_transaction();
let recorded = self
.undo_manager
.record_transaction(Change::new(range, &old_text, new_range, new_text), intent);
if recorded && !open_transaction {
self.undo_manager
.record_selections(vec![selection_before], vec![selection_after]);
}
recorded
}
pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context<Self>) {
self.undo_manager.set_ignoring(true);
if let Some(replay) = self.undo_manager.undo() {
for change in &replay.changes {
let range_utf16 = self.range_to_utf16(&change.new_range.into());
self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx);
}
self.restore_selections(replay.selections);
self.mode
.restore_auto_closed_pairs(replay.auto_closed_pairs.unwrap_or_default());
}
self.undo_manager.set_ignoring(false);
}
pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context<Self>) {
self.undo_manager.set_ignoring(true);
if let Some(replay) = self.undo_manager.redo() {
for change in &replay.changes {
let range_utf16 = self.range_to_utf16(&change.old_range.into());
self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx);
}
self.restore_selections(replay.selections);
self.mode
.restore_auto_closed_pairs(replay.auto_closed_pairs.unwrap_or_default());
}
self.undo_manager.set_ignoring(false);
}
fn restore_selections(&mut self, selections: Option<Vec<CursorSelection>>) {
let Some(selections) = selections else {
return;
};
let len = self.text.len();
let restored: Vec<CursorSelection> = selections
.into_iter()
.map(|mut sel| {
sel.start = sel.start.min(len);
sel.end = sel.end.min(len);
sel
})
.collect();
self.selections.replace_all(restored);
self.selections.merge_overlapping();
}
pub fn cursor(&self) -> usize {
if let Some(ime_marked_range) = &self.ime_marked_range {
return ime_marked_range.end;
}
self.selections.active().cursor_offset()
}
pub(super) fn active_selection(&self) -> &CursorSelection {
self.selections.active()
}
pub(super) fn active_selection_mut(&mut self) -> &mut CursorSelection {
self.selections.active_mut()
}
pub(super) fn set_selection(&mut self, start: usize, end: usize) {
let active = self.active_selection_mut();
active.start = start;
active.end = end;
}
pub(super) fn set_cursor_to(&mut self, offset: usize) {
let active = self.active_selection_mut();
active.start = offset;
active.end = offset;
active.reversed = false;
}
pub fn visible_row_range(&self) -> Option<std::ops::Range<usize>> {
self.last_layout.as_ref().map(|l| l.visible_range.clone())
}
pub fn scroll_offset(&self) -> gpui::Point<gpui::Pixels> {
self.scroll_handle.offset()
}
pub fn set_scroll_offset(&mut self, offset: gpui::Point<gpui::Pixels>, cx: &mut Context<Self>) {
self.deferred_scroll_offset = Some(offset);
cx.notify();
}
pub fn line_height(&self) -> Option<gpui::Pixels> {
self.last_layout.as_ref().map(|l| l.line_height)
}
pub fn selected_range(&self) -> std::ops::Range<usize> {
(*self.selections.active()).into()
}
pub fn select_all(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.undo_manager.break_transaction_coalescing();
self.selections.remove_all_but_active();
self.set_selection(0, self.text.len());
cx.notify();
}
pub fn set_selected_range(&mut self, range: Range<usize>, cx: &mut Context<Self>) {
let end_bias = if range.start == range.end {
Bias::Left
} else {
Bias::Right
};
let start = self.text.clip_offset(range.start, Bias::Left);
let end = self.text.clip_offset(range.end, end_bias);
self.move_to(start, None, cx);
self.active_selection_mut().reversed = false;
self.selected_word_range = None;
self.select_to(end, cx);
}
pub(crate) fn index_for_mouse_position(&self, position: Point<Pixels>) -> (usize, bool) {
let (offset, line_end_affinity, _) = self.resolve_mouse_position(position);
(offset, line_end_affinity)
}
fn resolve_mouse_position(&self, position: Point<Pixels>) -> (usize, bool, usize) {
if self.text.len() == 0 {
return (0, false, 0);
}
let (Some(bounds), Some(last_layout)) =
(self.last_bounds.as_ref(), self.last_layout.as_ref())
else {
return (0, false, 0);
};
let line_height = last_layout.line_height;
let line_number_width = last_layout.line_number_width;
let inner_position = position - bounds.origin - point(line_number_width, px(0.));
let mut y_offset = last_layout.visible_top;
let mut last_line_pos = None;
for (vi, (line_layout, _buffer_line)) in last_layout
.lines
.iter()
.zip(last_layout.visible_buffer_lines.iter())
.enumerate()
{
let line_start_offset = last_layout.visible_line_byte_offsets[vi];
let line_origin = point(px(0.), y_offset);
let pos = inner_position - line_origin;
if self.is_single_line() {
let local_index = line_layout.closest_index_for_x(pos.x, last_layout);
return (
self.resolve_index(line_start_offset + local_index),
false,
0,
);
}
if let Some((local_index, line_end_affinity)) =
line_layout.closest_index_for_position(pos, last_layout)
{
return (
self.resolve_index(line_start_offset + local_index),
line_end_affinity,
line_layout.columns_past_line_end(pos, last_layout),
);
} else if pos.y < px(0.) {
return (self.resolve_index(line_start_offset), false, 0);
}
y_offset += line_layout.size(line_height).height;
last_line_pos = Some(pos);
}
let columns_past_line_end = last_layout
.lines
.last()
.zip(last_line_pos)
.map(|(line_layout, pos)| {
let last_row_top = (line_layout.size(line_height).height - line_height).max(px(0.));
line_layout.columns_past_line_end(point(pos.x, last_row_top), last_layout)
})
.unwrap_or(0);
(self.text.len(), false, columns_past_line_end)
}
fn resolve_index(&self, index: usize) -> usize {
if self.masked {
self.text.char_index_to_offset(index / MASK_CHAR.len_utf8())
} else {
index.min(self.text.len())
}
}
fn extend_selection(
sel: &mut CursorSelection,
offset: usize,
word_range: Option<CursorSelection>,
) {
if sel.reversed {
sel.start = offset;
} else {
sel.end = offset;
}
if sel.end < sel.start {
sel.reversed = !sel.reversed;
std::mem::swap(&mut sel.start, &mut sel.end);
}
if let Some(word_range) = word_range {
if sel.start > word_range.start {
sel.start = word_range.start;
}
if sel.end < word_range.end {
sel.end = word_range.end;
}
}
}
pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.select_to_with_affinity(offset, false, cx);
}
pub(crate) fn select_to_with_affinity(
&mut self,
offset: usize,
line_end_affinity: bool,
cx: &mut Context<Self>,
) {
M::clear_inline_completion(self, cx);
self.cursor_line_end_affinity = line_end_affinity;
let offset = self.cursor_boundary(offset, Bias::Left);
let word_range = self.selected_word_range;
Self::extend_selection(self.active_selection_mut(), offset, word_range);
if self.active_selection().is_empty() {
self.update_preferred_column();
}
cx.notify()
}
fn select_all_cursors_to(
&mut self,
f: impl Fn(&Self, &CursorSelection) -> usize,
cx: &mut Context<Self>,
) {
self.pause_blink_cursor(cx);
self.undo_manager.break_transaction_coalescing();
M::clear_inline_completion(self, cx);
let new_selections: Vec<CursorSelection> = self
.selections
.iter()
.map(|sel| {
let offset = self.cursor_boundary(f(self, sel), Bias::Left);
let mut new_sel = *sel;
Self::extend_selection(&mut new_sel, offset, None);
new_sel
})
.collect();
self.cursor_line_end_affinity = false;
self.selections.replace_all(new_selections);
self.selections.merge_overlapping();
if self.active_selection().is_empty() {
self.update_preferred_column();
}
self.scroll_to(self.cursor(), None, cx);
cx.notify()
}
pub fn unselect(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.undo_manager.break_transaction_coalescing();
let offset = self.cursor();
self.set_cursor_to(offset);
cx.notify()
}
#[inline]
pub(super) fn offset_from_utf16(&self, offset: usize) -> usize {
self.text.offset_utf16_to_offset(offset)
}
#[inline]
pub(super) fn offset_to_utf16(&self, offset: usize) -> usize {
self.text.offset_to_offset_utf16(offset)
}
#[inline]
pub(crate) fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
}
#[inline]
pub(super) fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
}
fn clamp_offset_to_visible_backward(&self, offset: usize) -> usize {
let line = self.text.offset_to_point(offset).row;
if self.display_map.is_buffer_line_hidden(line) {
for fold in self.display_map.folded_ranges() {
if line > fold.start_line && line <= fold.end_line {
return self.text.line_end_offset(fold.start_line);
}
}
}
offset
}
fn clamp_offset_to_visible_forward(&self, offset: usize) -> usize {
let line = self.text.offset_to_point(offset).row;
if self.display_map.is_buffer_line_hidden(line) {
for fold in self.display_map.folded_ranges() {
if line > fold.start_line && line <= fold.end_line {
return self.text.line_start_offset(fold.end_line);
}
}
}
offset
}
pub(super) fn cursor_boundary(&self, offset: usize, bias: Bias) -> usize {
let offset = self.text.clip_offset(offset, bias);
if offset > 0
&& self.text.char_at(offset - 1) == Some('\r')
&& self.text.char_at(offset) == Some('\n')
{
if bias == Bias::Left {
offset - 1
} else {
offset + 1
}
} else {
offset
}
}
pub(super) fn previous_boundary(&self, offset: usize) -> usize {
let offset = self.cursor_boundary(offset.saturating_sub(1), Bias::Left);
self.clamp_offset_to_visible_backward(offset)
}
pub(super) fn next_boundary(&self, offset: usize) -> usize {
let offset = self.cursor_boundary(offset.saturating_add(1), Bias::Right);
self.clamp_offset_to_visible_forward(offset)
}
pub(crate) fn show_cursor(&self, window: &Window, cx: &App) -> bool {
(self.focus_handle.is_focused(window) || M::is_context_menu_open(self, cx))
&& !self.disabled
&& self.blink_cursor.read(cx).visible()
&& window.is_window_active()
}
fn on_focus(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.blink_cursor.update(cx, |cursor, cx| {
cursor.start(cx);
});
cx.emit(InputEvent::Focus);
}
fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if M::is_context_menu_open(self, cx) {
return;
}
self.undo_manager.break_transaction_coalescing();
M::clear_hover_state(self, cx);
self.diagnostic_popover = None;
M::clear_inline_completion(self, cx);
self.blink_cursor.update(cx, |cursor, cx| {
cursor.stop(cx);
});
self.clamp_number_value(window, cx);
cx.emit(InputEvent::Blur);
cx.notify();
}
fn clamp_number_value(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.is_single_line() {
return;
}
if !matches!(self.mask_pattern, MaskPattern::Number { .. }) {
return;
}
if self.number_min.is_none() && self.number_max.is_none() {
return;
}
let Ok(value) = self.unmask_value().parse::<f64>() else {
return;
};
let clamped = match (self.number_min, self.number_max) {
(Some(min), _) if value < min => min,
(_, Some(max)) if value > max => max,
_ => return,
};
let new_text = clamped.to_string();
if !self.is_valid_input(&new_text, cx) {
return;
}
let range = self.range_to_utf16(&(0..self.text.len()));
self.replace_text_in_range_silent(Some(range), &new_text, window, cx);
}
pub(super) fn pause_blink_cursor(&mut self, cx: &mut Context<Self>) {
self.blink_cursor.update(cx, |cursor, cx| {
cursor.pause(cx);
});
}
pub(super) fn on_drag_move(
&mut self,
event: &MouseMoveEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.text.len() == 0 {
return;
}
if self.last_layout.is_none() {
return;
}
if !self.focus_handle.is_focused(window) {
return;
}
if !self.selecting {
return;
}
self.auto_scroll.last_drag_position = Some(event.position);
let (offset, line_end_affinity, columns_past_line_end) =
self.resolve_mouse_position(event.position);
if let Some(start) = self.column_select_start {
let end = ColumnarPoint::new(offset, columns_past_line_end);
self.build_columnar_selection(start, end, cx);
} else {
self.select_to_with_affinity(offset, line_end_affinity, cx);
}
if !self.is_single_line() {
let delta = AutoScroll::compute_delta(event.position.y, self.input_bounds);
let scroll_delta = delta.map(|d| -d);
self.auto_scroll.set(scroll_delta, cx, |delta, state, cx| {
let current = state.scroll_handle.offset();
state.update_scroll_offset(Some(point(current.x, current.y + delta)), cx);
if let Some(pos) = state.auto_scroll.last_drag_position {
let (offset, line_end_affinity, columns_past_line_end) =
state.resolve_mouse_position(pos);
if let Some(start) = state.column_select_start {
let end = ColumnarPoint::new(offset, columns_past_line_end);
state.build_columnar_selection(start, end, cx);
} else {
state.select_to_with_affinity(offset, line_end_affinity, cx);
}
}
});
}
}
fn normalize_input<'a>(&self, new_text: &'a str) -> Cow<'a, str> {
let normalized = if matches!(self.mask_pattern, MaskPattern::Number { .. }) {
normalize_number_input(new_text)
} else {
Cow::Borrowed(new_text)
};
if self.is_single_line() && normalized.contains(['\n', '\r']) {
Cow::Owned(normalized.replace(['\n', '\r'], ""))
} else {
normalized
}
}
pub(crate) fn is_valid_input(&self, new_text: &str, cx: &mut Context<Self>) -> bool {
if new_text.is_empty() {
return true;
}
if let Some(validate) = &self.validate {
if !validate(new_text, cx) {
return false;
}
}
if !self.mask_pattern.is_valid(new_text) {
return false;
}
let Some(pattern) = &self.pattern else {
return true;
};
pattern.is_match(new_text)
}
pub fn mask_pattern(mut self, pattern: impl Into<MaskPattern>) -> Self {
self.mask_pattern = pattern.into();
self.mask_pattern_set = true;
if let Some(placeholder) = self.mask_pattern.placeholder() {
self.placeholder = placeholder.into();
}
self
}
pub fn set_mask_pattern(
&mut self,
pattern: impl Into<MaskPattern>,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.mask_pattern = pattern.into();
self.mask_pattern_set = true;
if let Some(placeholder) = self.mask_pattern.placeholder() {
self.placeholder = placeholder.into();
}
cx.notify();
}
pub fn ensure_number_mask(&mut self) {
if self.mask_pattern_set {
return;
}
self.mask_pattern = MaskPattern::Number {
separator: None,
fraction: None,
};
}
pub(super) fn set_input_bounds(&mut self, new_bounds: Bounds<Pixels>, cx: &mut Context<Self>) {
let wrap_width_changed = self.input_bounds.size.width != new_bounds.size.width;
self.input_bounds = new_bounds;
if let Some(last_layout) = self.last_layout.as_ref() {
if wrap_width_changed {
let wrap_width = if !self.soft_wrap {
None
} else {
last_layout.wrap_width
};
self.display_map.on_layout_changed(wrap_width, cx);
if self.is_multi_line() {
self.mode.update_auto_grow(&self.display_map);
}
cx.notify();
}
}
}
pub fn selected_text(&self) -> RopeSlice<'_> {
let range_utf16 = self.range_to_utf16(&self.selected_range());
let range = self.range_from_utf16(&range_utf16);
self.text.slice(range)
}
pub fn range_to_bounds(&self, range: &Range<usize>) -> Option<Bounds<Pixels>> {
let Some(last_layout) = self.last_layout.as_ref() else {
return None;
};
let Some(last_bounds) = self.last_bounds else {
return None;
};
let (_, _, start_pos) = self.line_and_position_for_offset(range.start);
let (_, _, end_pos) = self.line_and_position_for_offset(range.end);
let Some(start_pos) = start_pos else {
return None;
};
let Some(end_pos) = end_pos else {
return None;
};
Some(Bounds::from_corners(
last_bounds.origin + start_pos,
last_bounds.origin + end_pos + point(px(0.), last_layout.line_height),
))
}
pub(crate) fn replace_text_in_range_silent(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.silent_replace_text = true;
self.replace_text_in_range(range_utf16, new_text, window, cx);
self.silent_replace_text = false;
}
pub(crate) fn replace_text_in_ranges(
&mut self,
edits: &[(Range<usize>, String)],
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.is_editable() || edits.is_empty() {
return;
}
let mut sorted: Vec<(Range<usize>, &str)> = edits
.iter()
.map(|(range, text)| (range.clone(), text.as_str()))
.collect();
sorted.sort_by_key(|edit| std::cmp::Reverse(edit.0.start));
#[cfg(debug_assertions)]
for pair in sorted.windows(2) {
debug_assert!(
pair[1].0.end <= pair[0].0.start,
"replace_text_in_ranges requires disjoint ranges"
);
}
let requested_intent = self.undo_manager.take_pending_intent();
let selection_before = *self.active_selection();
let original_selections: Vec<CursorSelection> = self.selections.iter().copied().collect();
let selections_before: Vec<CursorSelection> = self
.selections
.iter()
.map(|selection| {
Self::collapse_for_intent(
requested_intent.unwrap_or(EditIntent::Atomic),
*selection,
&(selection.start..selection.end),
)
})
.collect();
let group = sorted.len() > 1;
if group {
self.undo_manager.begin_transaction();
}
let auto_closed_pairs_before = self.mode.auto_closed_pairs().clone();
let mut recorded = false;
for (range, new_text) in &sorted {
let old_text = self.text.clone();
self.mode.adjust_auto_closed_pair(range, new_text.len());
self.text.replace(range.clone(), new_text);
M::adjust_annotations(self, range, new_text.len());
recorded |= self.push_history(
&old_text,
range,
new_text,
requested_intent,
selection_before,
None,
);
self.display_map
.adjust_folds_for_edit(&old_text, range, new_text);
self.display_map
.on_text_changed(&self.text, range, &Rope::from(*new_text), cx);
self.mode.update_highlighter(
super::mode::HighlighterUpdate {
selected_range: range,
old_text: &old_text,
new_text: &self.text,
change_text: new_text,
force: true,
},
window,
cx,
);
self.update_fold_candidates_incremental(range, new_text);
}
if group {
self.undo_manager.commit_transaction();
}
if let Some(diagnostics) = self.mode.diagnostics_mut() {
diagnostics.reset(&self.text)
}
M::refresh_language_features(self, window, cx);
self.update_search(cx);
let mut ascending: Vec<(Range<usize>, &str)> = sorted.clone();
ascending.sort_by_key(|edit| edit.0.start);
let text_len = self.text.len();
let mut delta: isize = 0;
let mut edit_results = Vec::with_capacity(ascending.len());
for (range, new_text) in &ascending {
let offset = ((range.start as isize + delta) as usize + new_text.len()).min(text_len);
edit_results.push((range.clone(), offset));
delta += new_text.len() as isize - (range.end as isize - range.start as isize);
}
let mut used = vec![false; edit_results.len()];
let mut new_selections: Vec<CursorSelection> = Vec::with_capacity(ascending.len());
for selection in original_selections {
if let Ok(index) = edit_results
.binary_search_by_key(&(selection.start, selection.end), |(range, _)| {
(range.start, range.end)
})
&& !used[index]
{
let offset = edit_results[index].1;
used[index] = true;
let mut selection = selection;
selection.place_at(offset, None);
new_selections.push(selection);
}
}
for (index, (_, offset)) in edit_results.into_iter().enumerate() {
if !used[index] {
new_selections.push(CursorSelection::new(
self.selections.generate_id(),
offset,
offset,
));
}
}
self.selections.replace_all(new_selections);
self.selections.merge_overlapping();
let selections_after: Vec<CursorSelection> = self.selections.iter().copied().collect();
if recorded {
self.undo_manager.record_auto_closed_pairs(
auto_closed_pairs_before,
self.mode.auto_closed_pairs().clone(),
);
self.undo_manager
.record_selections(selections_before, selections_after);
}
self.ime_marked_range.take();
self.update_preferred_column();
if self.is_multi_line() {
self.mode.update_auto_grow(&self.display_map);
}
if self.emit_events {
cx.emit(InputEvent::Change);
}
cx.notify();
}
fn update_fold_candidates(&mut self) {
if !self.mode.is_folding() {
return;
}
let Some(highlighter_rc) = self.mode.highlighter() else {
return;
};
let highlighter = highlighter_rc.borrow();
let Some(highlighter) = highlighter.as_ref() else {
return;
};
let fold_ranges = highlighter.fold_ranges(&self.text);
self.display_map.set_fold_candidates(fold_ranges);
}
fn update_fold_candidates_incremental(&mut self, edit_range: &Range<usize>, new_text: &str) {
if !self.mode.is_folding() {
return;
}
let Some(highlighter_rc) = self.mode.highlighter() else {
return;
};
let highlighter = highlighter_rc.borrow();
let Some(highlighter) = highlighter.as_ref() else {
return;
};
let new_end = edit_range.start + new_text.len();
self.display_map.update_fold_candidates_for_edit(
|range, text| highlighter.fold_ranges_for_edit(range, text),
edit_range.start..new_end,
&self.text,
);
}
}
impl<M: InputModeKind> EntityInputHandler for InputBaseState<M> {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<String> {
let range = self.range_from_utf16(&range_utf16);
adjusted_range.replace(self.range_to_utf16(&range));
Some(self.text.slice(range).to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selected_range()),
reversed: false,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.ime_marked_range
.map(|range| self.range_to_utf16(&range.into()))
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.ime_marked_range = None;
self.undo_manager.commit_transaction();
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let requested_intent = self.undo_manager.take_pending_intent();
if !self.is_editable() {
return;
}
let selection_before = *self.active_selection();
let ends_composition = self.ime_marked_range.is_some();
self.pause_blink_cursor(cx);
let new_text = self.normalize_input(new_text);
let new_text: &str = &new_text;
let range = range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.or(self.ime_marked_range.map(|range| {
let range = self.range_to_utf16(&(range.start..range.end));
self.range_from_utf16(&range)
}))
.unwrap_or(self.selected_range());
if range.is_empty()
&& range.start == self.cursor()
&& self.ime_marked_range.is_none()
&& !self.silent_replace_text
&& self.is_code_editor()
&& self.selections.is_single()
&& self.active_selection().is_empty()
{
if let Some(target) = self.skip_over_target(new_text) {
self.set_cursor_to(target);
self.update_preferred_column();
cx.notify();
return;
}
}
if self.is_multi_line() {
let multi_cursor = range_utf16.is_none()
&& self.ime_marked_range.is_none()
&& !self.selections.is_single();
if multi_cursor {
self.selections.merge_overlapping();
let mut edits: Vec<(Range<usize>, String)> = self
.selections
.iter()
.map(|sel| (sel.start..sel.end, new_text.to_string()))
.collect();
edits.sort_by_key(|(range, _)| range.start);
let intent =
requested_intent.unwrap_or_else(|| self.typing_intent(&edits, new_text));
self.undo_manager.begin_transaction_with(intent);
self.undo_manager.set_pending_intent(intent);
self.replace_text_in_ranges(&edits, window, cx);
self.undo_manager.commit_transaction();
} else {
if range_utf16.is_some() {
self.selections.remove_all_but_active();
}
if let Some(intent) = requested_intent {
self.undo_manager.set_pending_intent(intent);
}
if let Some((open_len, closer)) = self.auto_close_target(&range, new_text) {
let replacement = format!("{new_text}{closer}");
self.replace_text_in_ranges(&[(range.clone(), replacement)], window, cx);
let cursor = range.start + new_text.len();
self.mode.track_auto_closed_pair(
cursor - open_len..cursor,
cursor..cursor + closer.len(),
);
self.undo_manager
.record_auto_closed_pairs_after(self.mode.auto_closed_pairs().clone());
self.set_cursor_to(cursor);
self.update_preferred_column();
self.undo_manager.record_selections(
vec![selection_before],
self.selections.iter().copied().collect(),
);
} else {
self.replace_text_in_ranges(
&[(range.clone(), new_text.to_string())],
window,
cx,
);
}
}
if ends_composition {
self.undo_manager.commit_transaction();
}
if !self.silent_replace_text {
M::on_text_typed(self, &range, new_text, window, cx);
}
return;
}
let old_text = self.text.clone();
self.mode.adjust_auto_closed_pair(&range, new_text.len());
self.text.replace(range.clone(), new_text);
let mut new_offset = (range.start + new_text.len()).min(self.text.len());
let mut mask_changed = false;
if self.is_single_line() {
let pending_text = self.text.to_string();
if !self.is_valid_input(&pending_text, cx)
&& self.is_valid_input(&old_text.to_string(), cx)
{
self.text = old_text;
return;
}
if !self.mask_pattern.is_none() {
let mask_text = self.mask_pattern.mask(&pending_text);
mask_changed = mask_text.as_str() != pending_text;
self.text = Rope::from(mask_text.as_str());
let new_text_len =
(new_text.len() + mask_text.len()).saturating_sub(pending_text.len());
new_offset = (range.start + new_text_len).min(mask_text.len());
}
}
if mask_changed {
M::reset_annotations(self);
} else {
M::adjust_annotations(self, &range, new_text.len());
}
if mask_changed {
self.push_history(
&old_text,
&(0..old_text.len()),
&self.text.to_string(),
Some(EditIntent::Atomic),
selection_before,
Some((new_offset..new_offset).into()),
);
} else {
self.push_history(
&old_text,
&range,
&new_text,
requested_intent,
selection_before,
None,
);
}
if let Some(diagnostics) = self.mode.diagnostics_mut() {
diagnostics.reset(&self.text)
}
self.display_map
.adjust_folds_for_edit(&old_text, &range, new_text);
self.display_map
.on_text_changed(&self.text, &range, &Rope::from(new_text), cx);
self.mode.update_highlighter::<M>(
super::mode::HighlighterUpdate {
selected_range: &range,
old_text: &old_text,
new_text: &self.text,
change_text: &new_text,
force: true,
},
window,
cx,
);
self.update_fold_candidates_incremental(&range, new_text);
M::refresh_language_features(self, window, cx);
self.set_cursor_to(new_offset);
self.ime_marked_range.take();
if ends_composition {
self.undo_manager
.record_selections(vec![selection_before], vec![*self.active_selection()]);
self.undo_manager.commit_transaction();
}
self.update_preferred_column();
self.update_search(cx);
if self.is_multi_line() {
self.mode.update_auto_grow(&self.display_map);
}
if !self.silent_replace_text {
M::on_text_typed(self, &range, &new_text, window, cx);
}
if self.emit_events {
cx.emit(InputEvent::Change);
}
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let requested_intent = self.undo_manager.take_pending_intent();
if !self.is_editable() {
return;
}
let selection_before = *self.active_selection();
let starts_composition = self.ime_marked_range.is_none();
if starts_composition {
self.undo_manager.begin_transaction();
}
self.selections.remove_all_but_active();
M::reset_language_features(self);
let new_text = self.normalize_input(new_text);
let new_text: &str = &new_text;
let range = range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.or(self.ime_marked_range.map(|range| {
let range = self.range_to_utf16(&(range.start..range.end));
self.range_from_utf16(&range)
}))
.unwrap_or(self.selected_range());
let auto_closed_pairs_before = self.mode.auto_closed_pairs().clone();
let old_text = self.text.clone();
self.mode.adjust_auto_closed_pair(&range, new_text.len());
self.text.replace(range.clone(), new_text);
if self.is_single_line() {
let pending_text = self.text.to_string();
if !self.is_valid_input(&pending_text, cx)
&& self.is_valid_input(&old_text.to_string(), cx)
{
self.text = old_text;
if starts_composition {
self.undo_manager.commit_transaction();
}
return;
}
}
M::adjust_annotations(self, &range, new_text.len());
if let Some(diagnostics) = self.mode.diagnostics_mut() {
diagnostics.reset(&self.text)
}
self.display_map
.adjust_folds_for_edit(&old_text, &range, new_text);
self.display_map
.on_text_changed(&self.text, &range, &Rope::from(new_text), cx);
self.mode.update_highlighter::<M>(
super::mode::HighlighterUpdate {
selected_range: &range,
old_text: &old_text,
new_text: &self.text,
change_text: &new_text,
force: true,
},
window,
cx,
);
self.update_fold_candidates_incremental(&range, new_text);
M::refresh_language_features(self, window, cx);
if new_text.is_empty() {
self.set_cursor_to(range.start);
self.ime_marked_range = None;
} else {
self.ime_marked_range = Some((range.start..range.start + new_text.len()).into());
let new_range = new_selected_range_utf16
.as_ref()
.map(|range_utf16| {
let new_text = Rope::from(new_text);
range.start + new_text.offset_utf16_to_offset(range_utf16.start)
..range.start + new_text.offset_utf16_to_offset(range_utf16.end)
})
.unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len());
self.set_selection(new_range.start, new_range.end);
}
if self.is_multi_line() {
self.mode.update_auto_grow(&self.display_map);
}
if self.push_history(
&old_text,
&range,
new_text,
requested_intent,
selection_before,
Some(*self.active_selection()),
) {
self.undo_manager
.record_selections(vec![selection_before], vec![*self.active_selection()]);
self.undo_manager.record_auto_closed_pairs(
auto_closed_pairs_before,
self.mode.auto_closed_pairs().clone(),
);
}
if new_text.is_empty() {
self.undo_manager.commit_transaction();
}
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let last_layout = self.last_layout.as_ref()?;
let line_height = last_layout.line_height;
let line_number_width = last_layout.line_number_width;
let range = self.range_from_utf16(&range_utf16);
let mut start_origin = None;
let mut end_origin = None;
let line_number_origin = point(line_number_width, px(0.));
let mut y_offset = last_layout.visible_top;
for (vi, line) in last_layout.lines.iter().enumerate() {
if start_origin.is_some() && end_origin.is_some() {
break;
}
let index_offset = last_layout.visible_line_byte_offsets[vi];
if start_origin.is_none() {
if let Some(p) = line.position_for_index(
range.start.saturating_sub(index_offset),
last_layout,
false,
) {
start_origin = Some(p + point(px(0.), y_offset));
}
}
if end_origin.is_none() {
if let Some(p) = line.position_for_index(
range.end.saturating_sub(index_offset),
last_layout,
false,
) {
end_origin = Some(p + point(px(0.), y_offset));
}
}
y_offset += line.size(line_height).height;
}
let start_origin = start_origin.unwrap_or_default();
let mut end_origin = end_origin.unwrap_or_default();
end_origin.y = start_origin.y;
Some(Bounds::from_corners(
bounds.origin + line_number_origin + start_origin,
bounds.origin + line_number_origin + point(end_origin.x, end_origin.y + line_height),
))
}
fn character_index_for_point(
&mut self,
point: gpui::Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
let last_layout = self.last_layout.as_ref()?;
let line_point = self.last_bounds?.localize(&point)?;
for (vi, line) in last_layout.lines.iter().enumerate() {
let offset = last_layout.visible_line_byte_offsets[vi];
if let Some(utf8_index) = line.index_for_position(line_point, last_layout) {
return Some(self.offset_to_utf16(offset + utf8_index));
}
}
None
}
}
impl<M: InputModeKind> Focusable for InputBaseState<M> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<M: InputModeKind> Render for InputBaseState<M> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.editor_style = self
.projected_editor_style
.resolved(&crate::Theme::global(cx).tokens);
let entity = cx.entity();
if self._pending_update {
self.mode.update_highlighter::<M>(
super::mode::HighlighterUpdate {
selected_range: &(0..0),
old_text: &self.text,
new_text: &self.text,
change_text: "",
force: false,
},
window,
cx,
);
self.update_fold_candidates();
M::refresh_language_features(self, window, cx);
self._pending_update = false;
}
let element = div()
.id("input-state")
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.when(self.is_editable(), |this| {
this.on_action(window.listener_for(&entity, InputBaseState::backspace))
.on_action(window.listener_for(&entity, InputBaseState::delete))
.on_action(
window.listener_for(&entity, InputBaseState::delete_to_beginning_of_line),
)
.on_action(window.listener_for(&entity, InputBaseState::delete_to_end_of_line))
.on_action(window.listener_for(&entity, InputBaseState::delete_previous_word))
.on_action(window.listener_for(&entity, InputBaseState::delete_next_word))
.on_action(window.listener_for(&entity, InputBaseState::enter))
.on_action(window.listener_for(&entity, InputBaseState::escape))
.on_action(window.listener_for(&entity, InputBaseState::paste))
.on_action(window.listener_for(&entity, InputBaseState::cut))
.on_action(window.listener_for(&entity, InputBaseState::undo))
.on_action(window.listener_for(&entity, InputBaseState::redo))
.when(self.is_multi_line(), |this| {
this.on_action(window.listener_for(&entity, InputBaseState::indent_inline))
.on_action(window.listener_for(&entity, InputBaseState::outdent_inline))
.on_action(window.listener_for(&entity, InputBaseState::indent_block))
.on_action(window.listener_for(&entity, InputBaseState::outdent_block))
})
})
.on_action(window.listener_for(&entity, InputBaseState::left))
.on_action(window.listener_for(&entity, InputBaseState::right))
.on_action(window.listener_for(&entity, InputBaseState::select_left))
.on_action(window.listener_for(&entity, InputBaseState::select_right))
.when(self.is_multi_line(), |this| {
this.on_action(window.listener_for(&entity, InputBaseState::up))
.on_action(window.listener_for(&entity, InputBaseState::down))
.on_action(window.listener_for(&entity, InputBaseState::select_up))
.on_action(window.listener_for(&entity, InputBaseState::select_down))
.on_action(window.listener_for(&entity, InputBaseState::page_up))
.on_action(window.listener_for(&entity, InputBaseState::page_down))
.on_action(window.listener_for(&entity, InputBaseState::add_cursor_above))
.on_action(window.listener_for(&entity, InputBaseState::add_cursor_below))
})
.on_action(window.listener_for(&entity, InputBaseState::on_action_select_all))
.on_action(window.listener_for(&entity, InputBaseState::select_to_start_of_line))
.on_action(window.listener_for(&entity, InputBaseState::select_to_end_of_line))
.on_action(window.listener_for(&entity, InputBaseState::select_to_previous_word))
.on_action(window.listener_for(&entity, InputBaseState::select_to_next_word))
.on_action(window.listener_for(&entity, InputBaseState::home))
.on_action(window.listener_for(&entity, InputBaseState::end))
.on_action(window.listener_for(&entity, InputBaseState::move_to_start))
.on_action(window.listener_for(&entity, InputBaseState::move_to_end))
.on_action(window.listener_for(&entity, InputBaseState::move_to_previous_word))
.on_action(window.listener_for(&entity, InputBaseState::move_to_next_word))
.on_action(window.listener_for(&entity, InputBaseState::select_to_start))
.on_action(window.listener_for(&entity, InputBaseState::select_to_end))
.on_action(window.listener_for(&entity, InputBaseState::show_character_palette))
.on_action(window.listener_for(&entity, InputBaseState::copy))
.on_action(window.listener_for(&entity, InputBaseState::on_action_search))
.on_action(window.listener_for(&entity, InputBaseState::on_action_replace))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&entity, InputBaseState::on_mouse_down),
)
.on_mouse_down(
MouseButton::Right,
window.listener_for(&entity, InputBaseState::on_mouse_down),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&entity, InputBaseState::on_mouse_up),
)
.on_mouse_up(
MouseButton::Right,
window.listener_for(&entity, InputBaseState::on_mouse_up),
)
.on_mouse_move(window.listener_for(&entity, InputBaseState::on_mouse_move))
.on_scroll_wheel(window.listener_for(&entity, InputBaseState::on_scroll_wheel))
.when(self.is_multi_line() && !self.disabled, |this| {
this.on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
})
.when(!self.disabled, |this| {
if self.is_multi_line() && window.modifiers().alt {
this.cursor_crosshair()
} else {
this.cursor_text()
}
})
.flex_1()
.when(self.is_multi_line(), |this| this.h_full())
.flex_grow_1()
.overflow_x_hidden()
.when(self.is_multi_line(), |this| {
this.pt(self.editor_paddings.top)
.pr(self.editor_paddings.right)
.pb(self.editor_paddings.bottom)
.pl(self.editor_paddings.left)
})
.child(TextElement::new(entity.clone()).placeholder(self.placeholder.clone()))
.when(self.shows_scrollbar(), |this| {
this.child(EditorScrollbar::new(entity.clone()))
});
M::register_actions(element, &entity, window)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
use gpui::{TestAppContext, VisualTestContext, size};
use crate::input::{EditorMode, EditorState, InputMode, LanguageConfig, TextareaMode};
fn set_test_syntax_provider(
provider: Rc<dyn crate::input::SyntaxContextProvider>,
cx: &mut App,
) {
struct TestLanguages(Rc<dyn crate::input::SyntaxContextProvider>);
impl crate::input::LanguageProvider for TestLanguages {
fn syntax_context_provider(
&self,
_: &str,
) -> Option<Rc<dyn crate::input::SyntaxContextProvider>> {
Some(self.0.clone())
}
}
crate::input::set_language_provider(Rc::new(TestLanguages(provider)), cx);
}
struct TestRoot<M: InputModeKind>(Entity<InputBaseState<M>>);
impl<M: InputModeKind> Render for TestRoot<M> {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.0.clone())
}
}
struct InputView<M: InputModeKind> {
input: Entity<InputBaseState<M>>,
window_handle: gpui::WindowHandle<TestRoot<M>>,
}
impl<M: InputModeKind> InputView<M> {
fn build_with(
cx: &mut TestAppContext,
make: impl FnOnce(&mut Window, &mut Context<InputBaseState<M>>) -> InputBaseState<M>
+ 'static,
) -> Self {
let mut input: Option<Entity<InputBaseState<M>>> = None;
let window = cx.update(|cx| {
cx.open_window(Default::default(), |window, cx| {
cx.set_global(Theme::default());
super::super::init(cx);
input = Some(cx.new(|cx| make(window, cx)));
cx.new(|_| TestRoot(input.clone().unwrap()))
})
.unwrap()
});
Self {
input: input.clone().unwrap(),
window_handle: window,
}
}
}
impl InputView<EditorMode> {
fn new(cx: &mut TestAppContext) -> Self {
Self::build_editor(cx, |state| state)
}
fn build_editor(
cx: &mut TestAppContext,
f: impl FnOnce(InputBaseState<EditorMode>) -> InputBaseState<EditorMode> + 'static,
) -> Self {
Self::build_with(cx, move |window, cx| {
f(crate::input::EditorState::new(window, cx).language("sql"))
})
}
}
impl InputView<TextareaMode> {
fn build_textarea(
cx: &mut TestAppContext,
f: impl FnOnce(InputBaseState<TextareaMode>) -> InputBaseState<TextareaMode> + 'static,
) -> Self {
Self::build_with(cx, move |window, cx| {
f(crate::input::TextareaState::new(window, cx))
})
}
}
impl InputView<InputMode> {
fn build(
cx: &mut TestAppContext,
f: impl FnOnce(InputBaseState<InputMode>) -> InputBaseState<InputMode> + 'static,
) -> Self {
Self::build_with(cx, move |window, cx| {
f(crate::input::InputState::new(window, cx))
})
}
}
#[gpui::test]
fn test_noop_scroll_notifies_diagnostic_dismissal(cx: &mut TestAppContext) {
use std::{cell::Cell, rc::Rc};
cx.update(crate::init);
let mut input = None;
let window = cx.open_window(size(px(400.), px(100.)), |window, cx| {
input = Some(cx.new(|cx| crate::input::EditorState::new(window, cx)));
gpui::EmptyView
});
let input = input.unwrap();
input.update(cx, |state, cx| {
state.input_bounds = Bounds::new(Point::default(), size(px(100.), px(20.)));
state.scroll_size = size(px(100.), px(20.));
state.present_diagnostic(crate::input::DiagnosticEntry::default(), cx);
});
let notifications = Rc::new(Cell::new(0));
let count = notifications.clone();
let _subscription =
cx.update(|cx| cx.observe(&input, move |_, _| count.set(count.get() + 1)));
window
.update(cx, |_, window, cx| {
input.update(cx, |state, cx| {
state.on_scroll_wheel(
&ScrollWheelEvent {
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-40.))),
..Default::default()
},
window,
cx,
);
assert_eq!(state.scroll_handle.offset(), Point::default());
assert!(state.diagnostic_popover().is_none());
});
})
.unwrap();
assert_eq!(
notifications.get(),
1,
"clearing a diagnostic must notify even when scrolling is clamped"
);
window
.update(cx, |_, window, cx| {
input.update(cx, |state, cx| {
state.on_scroll_wheel(
&ScrollWheelEvent {
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-40.))),
..Default::default()
},
window,
cx,
);
});
})
.unwrap();
assert_eq!(notifications.get(), 1, "an unchanged input must stay quiet");
}
#[gpui::test]
fn test_cursor_layout_consumer_updates_after_selection(cx: &mut TestAppContext) {
use std::{cell::Cell, rc::Rc};
struct Panel {
input: Entity<crate::input::EditorState>,
observed: Rc<Cell<Option<(Bounds<Pixels>, Pixels)>>>,
}
impl Render for Panel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.observed.set(self.input.read(cx).cursor_layout());
div().size_full().child(self.input.clone())
}
}
struct Root(Entity<Panel>);
impl Render for Root {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(
self.0
.clone()
.cached(gpui::StyleRefinement::default().size_full()),
)
}
}
cx.update(crate::init);
let observed = Rc::new(Cell::new(None));
let mut input = None;
let window = cx.open_window(size(px(400.), px(100.)), |window, cx| {
let state = cx.new(|cx| {
let mut state = crate::input::EditorState::new(window, cx);
state.set_value("abcdefgh", window, cx);
state
});
input = Some(state.clone());
Root(cx.new(|_| Panel {
input: state,
observed: observed.clone(),
}))
});
cx.run_until_parked();
let input = input.unwrap();
let before = input.read_with(cx, |state, _| state.cursor_layout());
assert_eq!(observed.get(), before);
window
.update(cx, |_, _, cx| {
input.update(cx, |state, cx| state.select_to_with_affinity(3, false, cx));
})
.unwrap();
cx.run_until_parked();
window.update(cx, |_, _, cx| cx.notify()).unwrap();
let after = input.read_with(cx, |state, _| state.cursor_layout());
assert_ne!(after, before, "the caret must actually move");
assert_eq!(
observed.get(),
after,
"render consumers must receive the newly painted caret geometry"
);
}
#[gpui::test]
fn test_input_scroll_offset_notifies_only_on_change(cx: &mut TestAppContext) {
use std::{cell::Cell, rc::Rc};
cx.update(crate::init);
let mut input = None;
let _window = cx.open_window(size(px(400.), px(100.)), |window, cx| {
input = Some(cx.new(|cx| crate::input::InputState::new(window, cx)));
gpui::EmptyView
});
let input = input.unwrap();
let notifications = Rc::new(Cell::new(0));
let count = notifications.clone();
let _subscription =
cx.update(|cx| cx.observe(&input, move |_, _| count.set(count.get() + 1)));
input.update(cx, |state, _| {
state.input_bounds = Bounds::new(Point::default(), size(px(100.), px(20.)));
state.scroll_size = size(px(300.), px(20.));
});
for target in [
None,
Some(point(px(0.), px(0.))),
Some(point(px(20.), px(50.))),
] {
input.update(cx, |state, cx| state.update_scroll_offset(target, cx));
assert_eq!(
notifications.get(),
0,
"an unchanged clamped offset must stay quiet"
);
}
input.update(cx, |state, cx| {
state.update_scroll_offset(Some(point(px(-40.), px(0.))), cx);
assert_eq!(state.scroll_handle.offset(), point(px(-40.), px(0.)));
});
assert_eq!(notifications.get(), 1, "a scroll must notify");
input.update(cx, |state, cx| {
state.update_scroll_offset(Some(point(px(-400.), px(50.))), cx);
assert_eq!(state.scroll_handle.offset(), point(px(-200.), px(0.)));
});
assert_eq!(notifications.get(), 2);
input.update(cx, |state, cx| {
state.update_scroll_offset(Some(point(px(-500.), px(0.))), cx);
});
assert_eq!(
notifications.get(),
2,
"clamping to the current offset must stay quiet"
);
input.update(cx, |state, cx| {
state.scroll_size.width = px(110.);
state.update_scroll_offset(None, cx);
assert_eq!(state.scroll_handle.offset(), point(px(-10.), px(0.)));
});
assert_eq!(
notifications.get(),
3,
"a smaller scroll range must clamp and notify"
);
}
#[gpui::test]
fn test_input_does_not_invalidate_cached_parent_during_paint(cx: &mut TestAppContext) {
use std::{cell::Cell, rc::Rc};
struct Panel {
input: Entity<crate::input::InputState>,
renders: Rc<Cell<usize>>,
}
impl Render for Panel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.renders.set(self.renders.get() + 1);
div().size_full().child(self.input.clone())
}
}
struct Root(Entity<Panel>);
impl Render for Root {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(
self.0
.clone()
.cached(gpui::StyleRefinement::default().size_full()),
)
}
}
cx.update(crate::init);
let renders = Rc::new(Cell::new(0));
let mut input = None;
let window = cx.open_window(size(px(400.), px(100.)), |window, cx| {
let state = cx.new(|cx| crate::input::InputState::new(window, cx));
input = Some(state.clone());
Root(cx.new(|_| Panel {
input: state,
renders: renders.clone(),
}))
});
cx.run_until_parked();
let before = renders.get();
assert!(before > 0);
for _ in 0..60 {
window.update(cx, |_, _, cx| cx.notify()).unwrap();
}
assert_eq!(
renders.get(),
before,
"an idle input must not invalidate its cached parent"
);
let input = input.unwrap();
window
.update(cx, |_, window, cx| {
input.update(cx, |state, cx| state.set_value("changed", window, cx));
})
.unwrap();
assert!(
renders.get() > before,
"text changes must invalidate the parent"
);
window.update(cx, |_, _, cx| cx.notify()).unwrap();
let before = renders.get();
for _ in 0..60 {
window.update(cx, |_, _, cx| cx.notify()).unwrap();
}
assert_eq!(renders.get(), before, "the edited input must settle again");
cx.simulate_window_resize(window.into(), size(px(240.), px(100.)));
cx.run_until_parked();
assert!(renders.get() > before, "resizing must invalidate layout");
}
#[gpui::test]
fn textarea_cursor_treats_crlf_as_one_newline(cx: &mut TestAppContext) {
cx.update(crate::init);
let view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
for original in ["\r\nlast", "\u{feff}first\r\nlast\n", "first\nlast\r\n"] {
state.set_value(original, window, cx);
let newline = original.find('\n').unwrap();
let end = if original.as_bytes()[newline.saturating_sub(1)] == b'\r' {
newline - 1
} else {
newline
};
state.end(&MoveEnd, window, cx);
assert_eq!(state.cursor(), end);
state.right(&MoveRight, window, cx);
assert_eq!(state.cursor(), newline + 1);
state.left(&MoveLeft, window, cx);
assert_eq!(state.cursor(), end);
state.select_to_end_of_line(&SelectToEndOfLine, window, cx);
assert_eq!(state.selected_range(), end..end);
state.move_to_end(&MoveToEnd, window, cx);
assert_eq!(state.cursor(), original.len());
state.move_to_start(&MoveToStart, window, cx);
assert_eq!(state.cursor(), 0);
assert_eq!(state.value().as_bytes(), original.as_bytes());
}
state.set_value("a\rb", window, cx);
state.right(&MoveRight, window, cx);
assert_eq!(state.cursor(), 1);
state.right(&MoveRight, window, cx);
assert_eq!(state.cursor(), 2);
})
});
}
#[gpui::test]
fn only_a_multi_line_input_paints_scrollbars(cx: &mut TestAppContext) {
cx.update(crate::init);
let single = InputView::build(cx, |state| state);
single
.input
.update(cx, |state, _| assert!(!state.shows_scrollbar()));
let multi = InputView::build_textarea(cx, |state| state);
multi
.input
.update(cx, |state, _| assert!(state.shows_scrollbar()));
}
#[gpui::test]
fn context_menu_handler_is_deferred_and_respects_disabled(cx: &mut TestAppContext) {
use std::{cell::Cell, rc::Rc};
cx.update(crate::init);
let input_view = InputView::new(cx);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
let calls = Rc::new(Cell::new(0usize));
let items = Rc::new(Cell::new(0usize));
cx.update(|window, cx| {
input.update(cx, |state, cx| {
let calls2 = calls.clone();
let items2 = items.clone();
state.on_context_menu(Rc::new(move |menu, _, _, _, _| {
calls2.set(calls2.get() + 1);
items2.set(menu.items.len());
}));
state.handle_right_click_menu(point(px(0.), px(0.)), 0, window, cx);
})
});
assert_eq!(calls.get(), 1);
assert_eq!(items.get(), 0);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.disabled = true;
state.handle_right_click_menu(point(px(0.), px(0.)), 0, window, cx);
})
});
assert_eq!(calls.get(), 1);
}
#[gpui::test]
fn test_readonly_rejects_user_edits_only(cx: &mut TestAppContext) {
let input_view = InputView::new(cx);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("hello", window, cx);
state.set_readonly(true, cx);
});
});
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert!(!state.is_editable());
assert!(!state.is_replaceable());
});
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, " world", window, cx);
state.replace_and_mark_text_in_range(None, "あ", None, window, cx);
});
});
cx.update(|_, cx| {
input.read_with(cx, |state, _| assert_eq!(state.value(), "hello"));
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.insert(" world", window, cx);
state.set_value("changed", window, cx);
});
});
cx.update(|_, cx| {
input.read_with(cx, |state, _| assert_eq!(state.value(), "changed"));
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_readonly(false, cx);
state.replace_text_in_range(None, "!", window, cx);
});
});
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert!(state.is_editable());
assert_eq!(state.value(), "!changed");
});
});
}
#[gpui::test]
fn test_scroll_to_eob_does_not_overshoot_safe_range(cx: &mut TestAppContext) {
let input_view = InputView::new(cx);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_scroll_beyond_last_line(Some(1), window, cx);
state.set_cursor_surrounding_lines(Some(1), window, cx);
let text: String = (1..=50)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
state.set_value(text, window, cx);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert!(
state.scroll_size.height > px(0.),
"scroll_size not populated by initial paint"
);
assert!(
state.input_bounds.size.height > px(0.),
"input_bounds not populated by initial paint"
);
});
});
cx.update(|_, cx| {
input.update(cx, |state, cx| {
let end = state.text.len();
state.move_to(end, Some(MoveDirection::Down), cx);
let deferred = state
.deferred_scroll_offset
.expect("scroll_to should populate deferred_scroll_offset");
let safe_y_min =
(-state.scroll_size.height + state.input_bounds.size.height).min(px(0.));
assert!(
deferred.y >= safe_y_min,
"deferred_scroll_offset.y = {:?} below safe_y_min = {:?} \
— paint would jitter (Bug C regression)",
deferred.y,
safe_y_min,
);
});
});
}
#[gpui::test]
fn test_next_search_match_reveals_with_padding_after_manual_scroll(cx: &mut TestAppContext) {
assert_search_reveals_with_padding_after_manual_scroll(false, cx);
}
#[gpui::test]
fn test_previous_search_match_reveals_with_padding_after_manual_scroll(
cx: &mut TestAppContext,
) {
assert_search_reveals_with_padding_after_manual_scroll(true, cx);
}
fn assert_search_reveals_with_padding_after_manual_scroll(
previous: bool,
cx: &mut TestAppContext,
) {
let input_view = InputView::new(cx);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
let text = (0..160)
.map(|row| {
if matches!(row, 20 | 60 | 100) {
format!("match on row {row}")
} else {
format!("line {row}")
}
})
.collect::<Vec<_>>()
.join("\n");
let start = text.match_indices("match").nth(1).unwrap().0;
let expected_match = start..start + "match".len();
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_cursor_surrounding_lines(Some(3), window, cx);
state.set_value(text, window, cx);
state.set_search_query("match", true, cx);
if previous {
state.search_session.matcher.next();
state.search_session.matcher.next();
}
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.update(cx, |state, cx| {
let line_height = state.last_layout.as_ref().unwrap().line_height;
let y = if previous { px(0.) } else { -line_height * 80. };
state.set_scroll_offset(point(px(0.), y), cx);
});
});
cx.run_until_parked();
input.read_with(&cx, |state, _| {
let visible = state.visible_row_range().unwrap();
if previous {
assert!(visible.end <= 60, "target must be below the viewport");
} else {
assert!(visible.start > 60, "target must be above the viewport");
}
});
cx.update(|_, cx| {
input.update(cx, |state, cx| {
let range = if previous {
state.previous_search_match(cx)
} else {
state.next_search_match(cx)
};
assert_eq!(range, Some(expected_match));
assert_eq!(state.search_session.matcher.label(), "2/3");
});
});
cx.run_until_parked();
input.read_with(&cx, |state, _| {
assert!(state.visible_row_range().unwrap().contains(&60));
let line_height = state.last_layout.as_ref().unwrap().line_height;
let target_y = line_height * 60. + state.scroll_handle.offset().y;
assert!(target_y >= line_height * 2. - px(0.1));
assert!(
target_y + line_height * 3.
<= state.last_bounds.as_ref().unwrap().size.height + px(0.1),
"search must preserve the configured surrounding-line padding"
);
});
}
#[gpui::test]
fn test_number_step(cx: &mut TestAppContext) {
let input = InputView::build(cx, |state| state).input;
cx.update(|cx| {
input.update(cx, |_state, cx| {
assert_eq!(
NumberStep::from(5.).value(123., StepAction::Increment, cx),
5.
);
let step = NumberStep::by_value(|value, action, _cx| {
let below = match action {
StepAction::Increment => value < 1.0,
StepAction::Decrement => value <= 1.0,
};
if below { 0.1 } else { 0.5 }
});
assert_eq!(step.value(0.5, StepAction::Increment, cx), 0.1);
assert_eq!(step.value(1.0, StepAction::Increment, cx), 0.5);
assert_eq!(step.value(1.0, StepAction::Decrement, cx), 0.1);
assert_eq!(step.value(2.0, StepAction::Decrement, cx), 0.5);
});
});
}
#[gpui::test]
fn test_number_input_normalization(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state.mask_pattern(MaskPattern::Number {
separator: None,
fraction: None,
})
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "12。5", window, cx);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert_eq!(state.value(), "12.5");
let cursor: Range<usize> = state.selected_range();
assert_eq!(cursor, 4..4);
});
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "abc", window, cx);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert_eq!(state.value(), "12.5");
});
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
let range = state.range_to_utf16(&(0..state.text.len()));
state.replace_text_in_range(Some(range), "。", window, cx);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert_eq!(state.value(), ".");
let cursor: Range<usize> = state.selected_range();
assert_eq!(cursor, 1..1);
});
});
}
#[gpui::test]
fn test_number_input_normalization_with_separator(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state.mask_pattern(MaskPattern::Number {
separator: Some(','),
fraction: Some(2),
})
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "1234", window, cx);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert_eq!(state.value(), "1,234");
assert_eq!(state.unmask_value(), "1234");
});
});
}
#[gpui::test]
fn test_number_input_clamp_on_blur(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state
.mask_pattern(MaskPattern::Number {
separator: None,
fraction: None,
})
.min(10.)
.max(100.)
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "1000", window, cx);
assert_eq!(state.value(), "1000");
state.clamp_number_value(window, cx);
assert_eq!(state.value(), "100");
let range = state.range_to_utf16(&(0..state.text.len()));
state.replace_text_in_range(Some(range), "1", window, cx);
assert_eq!(state.value(), "1");
state.clamp_number_value(window, cx);
assert_eq!(state.value(), "10");
});
});
}
#[gpui::test]
fn test_number_input_undo_with_mask(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state.mask_pattern(MaskPattern::Number {
separator: Some(','),
fraction: None,
})
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "1234", window, cx);
assert_eq!(state.value(), "1,234");
state.replace_text_in_range(None, "5", window, cx);
assert_eq!(state.value(), "12,345");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "1,234");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "1,234");
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "12,345");
});
});
}
#[gpui::test]
fn test_undo_manager_coalesces_adjacent_typing_transactions(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.replace_text_in_range(None, "b", window, cx);
assert_eq!(state.value(), "ab");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_cursor_movement_splits_typing(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.replace_text_in_range(None, "b", window, cx);
state.left(&MoveLeft, window, cx);
state.replace_text_in_range(None, "x", window, cx);
assert_eq!(state.value(), "axb");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "ab");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_splits_backward_and_forward_delete(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("abcd", window, cx);
state.set_selected_range(2..2, cx);
state.backspace(&Backspace, window, cx);
state.delete(&Delete, window, cx);
assert_eq!(state.value(), "ad");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "acd");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abcd");
});
});
}
#[gpui::test]
fn test_undo_manager_coalesces_directional_character_deletes(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("abcd", window, cx);
state.backspace(&Backspace, window, cx);
state.backspace(&Backspace, window, cx);
assert_eq!(state.value(), "ab");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abcd");
assert_eq!(state.selected_range(), 4..4);
state.set_value("abcd", window, cx);
state.set_selected_range(1..1, cx);
state.delete(&Delete, window, cx);
state.delete(&Delete, window, cx);
assert_eq!(state.value(), "ad");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abcd");
assert_eq!(state.selected_range(), 1..1);
});
});
}
#[gpui::test]
fn test_undo_manager_atomic_paste_isolated_from_typing(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string("P".to_string()));
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.paste(&Paste, window, cx);
state.replace_text_in_range(None, "b", window, cx);
assert_eq!(state.value(), "aPb");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "aP");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "a");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_programmatic_insert_is_atomic(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.insert("P", window, cx);
state.replace_text_in_range(None, "b", window, cx);
assert_eq!(state.value(), "aPb");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "aP");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "a");
});
});
}
#[gpui::test]
fn test_undo_manager_selection_round_trip_splits_typing(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.replace_text_in_range(None, "b", window, cx);
state.select_all(window, cx);
state.unselect(window, cx);
state.replace_text_in_range(None, "c", window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "ab");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_enter_is_atomic(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
state.replace_text_in_range(None, "b", window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "a\n");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "a");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_single_line_return_commits_the_typing_session(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
for part in ["a", "b", "c"] {
state.replace_text_in_range(None, part, window, cx);
}
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
for part in ["d", "e", "f"] {
state.replace_text_in_range(None, part, window, cx);
}
assert_eq!(state.value(), "abcdef");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abc");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_submit_on_enter_commits_the_textarea_session(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state.submit_on_enter(true));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "before submit", window, cx);
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
state.replace_text_in_range(None, " after submit", window, cx);
assert_eq!(state.value(), "before submit after submit");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "before submit");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_blur_commits_the_typing_session(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "before blur", window, cx);
state.on_blur(window, cx);
state.on_focus(window, cx);
state.replace_text_in_range(None, " after focus", window, cx);
assert_eq!(state.value(), "before blur after focus");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "before blur");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_keeps_rapid_lines_in_distinct_transactions(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
let enter = Enter {
secondary: false,
shift: false,
};
state.replace_text_in_range(None, "a", window, cx);
state.enter(&enter, window, cx);
state.replace_text_in_range(None, "b", window, cx);
state.enter(&enter, window, cx);
state.replace_text_in_range(None, "c", window, cx);
assert_eq!(state.value(), "a\nb\nc");
for expected in ["a\nb\n", "a\nb", "a\n", "a", ""] {
state.undo(&Undo, window, cx);
assert_eq!(state.value(), expected);
}
});
});
}
#[gpui::test]
fn test_undo_manager_coalesces_long_unicode_typing_without_a_timer(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
let parts = [
"The ",
"quick ",
"brown fox, ",
"你好,世界 ",
"🦀 jumps over 13 lazy dogs.",
];
let expected = parts.concat();
cx.update(|window, cx| {
input.update(cx, |state, cx| {
for part in parts {
state.replace_text_in_range(None, part, window, cx);
}
assert_eq!(state.value(), expected);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
state.redo(&Redo, window, cx);
assert_eq!(state.value(), expected);
});
});
}
#[gpui::test]
fn test_undo_manager_long_multiline_sequence_has_structural_boundaries(
cx: &mut TestAppContext,
) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
let enter = Enter {
secondary: false,
shift: false,
};
cx.update(|window, cx| {
input.update(cx, |state, cx| {
for (index, line) in [
"first line with punctuation!",
"第二行包含 Unicode 🦀",
"third line has several words",
]
.into_iter()
.enumerate()
{
for chunk in line.split_inclusive(' ') {
state.replace_text_in_range(None, chunk, window, cx);
}
if index < 2 {
state.enter(&enter, window, cx);
}
}
assert_eq!(
state.value(),
"first line with punctuation!\n第二行包含 Unicode 🦀\nthird line has several words"
);
state.undo(&Undo, window, cx);
assert_eq!(
state.value(),
"first line with punctuation!\n第二行包含 Unicode 🦀\n"
);
state.undo(&Undo, window, cx);
assert_eq!(
state.value(),
"first line with punctuation!\n第二行包含 Unicode 🦀"
);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "first line with punctuation!\n");
});
});
}
#[gpui::test]
fn test_masked_input_keeps_its_value_out_of_the_clipboard(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("hunter2", window, cx);
state.set_masked(true, window, cx);
state.select_all(window, cx);
cx.write_to_clipboard(ClipboardItem::new_string("sentinel".into()));
state.copy(&Copy, window, cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("sentinel".to_string())
);
state.cut(&Cut, window, cx);
assert_eq!(state.value(), "hunter2");
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("sentinel".to_string())
);
state.set_masked(false, window, cx);
state.copy(&Copy, window, cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("hunter2".to_string())
);
});
});
}
#[gpui::test]
fn test_masked_input_collapses_word_boundaries(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("aaa bbb ccc", window, cx);
state.set_masked(true, window, cx);
state.set_selected_range(7..7, cx);
state.delete_previous_word(&DeleteToPreviousWordStart, window, cx);
assert_eq!(state.value(), " ccc");
assert_eq!(state.selected_range(), 0..0);
state.delete_next_word(&DeleteToNextWordEnd, window, cx);
assert_eq!(state.value(), "");
state.set_value("aaa bbb ccc", window, cx);
state.select_word(9, window, cx);
assert_eq!(state.selected_range(), 0..11);
state.set_masked(false, window, cx);
state.set_value("aaa bbb ccc", window, cx);
state.set_selected_range(11..11, cx);
state.delete_previous_word(&DeleteToPreviousWordStart, window, cx);
assert_eq!(state.value(), "aaa bbb ");
state.set_value("aaa bbb ccc", window, cx);
state.select_word(9, window, cx);
assert_eq!(state.selected_range(), 8..11);
});
});
}
#[gpui::test]
fn test_masked_input_disables_the_copy_context_menu_items(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("hunter2", window, cx);
state.select_all(window, cx);
assert!(state.context_menu_capabilities().is_copyable());
state.set_masked(true, window, cx);
let capabilities = state.context_menu_capabilities();
assert!(capabilities.is_masked());
assert!(capabilities.has_selection());
assert!(!capabilities.is_copyable());
});
});
}
#[gpui::test]
fn test_undo_manager_cut_and_repeated_pastes_are_distinct_transactions(
cx: &mut TestAppContext,
) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "alpha beta gamma", window, cx);
state.set_selected_range(6..10, cx);
state.cut(&Cut, window, cx);
assert_eq!(state.value(), "alpha gamma");
state.paste(&Paste, window, cx);
state.paste(&Paste, window, cx);
assert_eq!(state.value(), "alpha betabeta gamma");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "alpha beta gamma");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "alpha gamma");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "alpha beta gamma");
});
});
}
#[gpui::test]
fn test_undo_manager_word_and_line_deletes_do_not_coalesce(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("one two three\nfour five", window, cx);
state.set_selected_range(13..13, cx);
state.delete_previous_word(&DeleteToPreviousWordStart, window, cx);
assert_eq!(state.value(), "one two \nfour five");
state.delete_to_end_of_line(&DeleteToEndOfLine, window, cx);
assert_eq!(state.value(), "one two four five");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "one two \nfour five");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "one two three\nfour five");
});
});
}
#[gpui::test]
fn test_undo_manager_multiline_replacement_is_one_atomic_transaction(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "before", window, cx);
state.set_selected_range(0..6, cx);
state.replace_text_in_range(None, "line one\nline two\n第三行", window, cx);
assert_eq!(state.value(), "line one\nline two\n第三行");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "before");
assert_eq!(state.selected_range(), 0..6);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "line one\nline two\n第三行");
});
});
}
#[gpui::test]
fn test_undo_manager_composition_isolated_from_long_typing(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "prefix ", window, cx);
state.replace_and_mark_text_in_range(None, "n", None, window, cx);
state.replace_and_mark_text_in_range(None, "ni", None, window, cx);
state.replace_and_mark_text_in_range(None, "你", None, window, cx);
state.unmark_text(window, cx);
state.replace_text_in_range(None, " suffix", window, cx);
assert_eq!(state.value(), "prefix 你 suffix");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "prefix 你");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "prefix ");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_selected_replacement_is_atomic(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "abc", window, cx);
state.set_selected_range(1..2, cx);
state.replace_text_in_range(None, "X", window, cx);
state.replace_text_in_range(None, "z", window, cx);
assert_eq!(state.value(), "aXzc");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "aXc");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abc");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_number_input_leading_dot_editable(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state.mask_pattern(MaskPattern::Number {
separator: None,
fraction: None,
})
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "1.2", window, cx);
let range = state.range_to_utf16(&(0..1));
state.replace_text_in_range(Some(range), "", window, cx);
assert_eq!(state.value(), ".2");
let cursor: Range<usize> = state.selected_range();
assert_eq!(cursor, 0..0);
state.replace_text_in_range(Some(0..0), "3", window, cx);
assert_eq!(state.value(), "3.2");
});
});
}
#[gpui::test]
fn test_number_input_escape_invalid_text(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state
.mask_pattern(MaskPattern::Number {
separator: None,
fraction: None,
})
.default_value("1,234")
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
let range = state.range_to_utf16(&(4..5));
state.replace_text_in_range(Some(range), "", window, cx);
assert_eq!(state.value(), "1,23");
let range = state.range_to_utf16(&(1..2));
state.replace_text_in_range(Some(range), "", window, cx);
assert_eq!(state.value(), "123");
state.replace_text_in_range(None, "a", window, cx);
assert_eq!(state.value(), "123");
});
});
}
#[gpui::test]
fn test_set_value_single_line_caret_at_end_view_at_start(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
let value = format!("https://example.com/v1/users?{}", "x=1&".repeat(120));
let len = value.len();
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value(value.clone(), window, cx);
assert_eq!(
state.selected_range(),
len..len,
"single-line caret should be at the end after set_value"
);
assert_eq!(
state.deferred_scroll_offset,
Some(point(px(0.), px(0.))),
"the view should be forced back to the start"
);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert!(
state.scroll_size.width > state.input_bounds.size.width,
"value must overflow the input width or this test is vacuous"
);
assert_eq!(
state.scroll_handle.offset().x,
px(0.),
"long value should display from its start, not its tail"
);
});
});
}
#[gpui::test]
fn test_replace_all_single_line(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
let value = format!("https://example.com/v1/users?{}", "x=1&".repeat(120));
let len = value.len();
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("hello", window, cx);
state.replace_all(value.clone(), window, cx);
assert_eq!(state.value(), value);
assert_eq!(
state.selected_range(),
len..len,
"single-line caret should be at the end after replace_all"
);
assert_eq!(
state.scroll_handle.offset(),
point(px(0.), px(0.)),
"the scroll offset should be reset to the start"
);
assert_eq!(
state.deferred_scroll_offset,
Some(point(px(0.), px(0.))),
"single-line should set a deferred scroll offset to keep the start visible"
);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.read_with(cx, |state, _| {
assert!(
state.scroll_size.width > state.input_bounds.size.width,
"value must overflow the input width or this test is vacuous"
);
assert_eq!(
state.scroll_handle.offset().x,
px(0.),
"long value should display from its start, not its tail"
);
});
});
}
#[gpui::test]
fn test_single_line_removes_newlines(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state.default_value("default\nvalue"));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
assert_eq!(state.value(), "defaultvalue");
state.set_value("first\nsecond\r\nthird\rfourth", window, cx);
assert_eq!(state.value(), "firstsecondthirdfourth");
state.set_value("", window, cx);
state.insert("a\nb", window, cx);
assert_eq!(state.value(), "ab");
});
cx.write_to_clipboard(ClipboardItem::new_string("a\r\nb\nc\rd".to_string()));
input.update(cx, |state, cx| {
state.set_value("", window, cx);
state.paste(&Paste, window, cx);
assert_eq!(state.value(), "abcd");
});
});
cx.run_until_parked();
}
#[gpui::test]
fn test_replace_all_multi_line(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("foo\nbar", window, cx);
state.replace_all("baz\nqux", window, cx);
assert_eq!(state.value(), "baz\nqux");
assert_eq!(
state.selected_range(),
0..0,
"multi-line selection should be cleared after replace_all"
);
assert_eq!(
state.scroll_handle.offset(),
point(px(0.), px(0.)),
"the scroll offset should be reset to the start"
);
assert!(
state.deferred_scroll_offset.is_none(),
"multi-line should not set a deferred scroll offset"
);
});
});
}
#[gpui::test]
fn test_replace_all_preserves_undo_history(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("first", window, cx);
assert!(
!state.undo_manager.has_undos(),
"history should be empty after set_value"
);
state.replace_all("second", window, cx);
assert_eq!(state.value(), "second");
assert!(
state.undo_manager.has_undos(),
"replace_all should record an undo step"
);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "first");
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "second");
});
});
}
#[gpui::test]
fn test_replace_all_code_editor(cx: &mut TestAppContext) {
let input_view = InputView::new(cx);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("select 1", window, cx);
state._pending_update = false;
state.replace_all("select 2", window, cx);
assert_eq!(state.value(), "select 2");
assert!(
state._pending_update,
"replace_all on a code editor should request a pending update"
);
});
});
}
#[gpui::test]
fn test_set_selected_range(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state.default_value("hello world"));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|_, cx| {
input.update(cx, |s, cx| {
s.set_selected_range(0..5, cx);
assert_eq!(s.selected_range(), 0..5);
assert_eq!(s.selected_text().to_string(), "hello");
s.set_selected_range(6..11, cx);
assert_eq!(s.selected_text().to_string(), "world");
s.set_selected_range(100..100, cx);
assert_eq!(s.selected_range(), 11..11);
});
});
}
#[gpui::test]
fn test_replace_text_in_ranges_single_edit(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state.default_value("hello world"));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |s, cx| {
s.replace_text_in_ranges(&[(0..5, "HELLO".to_string())], window, cx);
assert_eq!(s.value(), "HELLO world");
s.undo(&Undo, window, cx);
assert_eq!(s.value(), "hello world");
s.redo(&Redo, window, cx);
assert_eq!(s.value(), "HELLO world");
});
});
}
#[gpui::test]
fn test_replace_text_in_ranges_multi_edit_transaction(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state.default_value("aaa bbb ccc"));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |s, cx| {
s.replace_text_in_ranges(
&[(0..3, "X".to_string()), (8..11, "Y".to_string())],
window,
cx,
);
assert_eq!(s.value(), "X bbb Y");
let cursors: Vec<usize> =
s.selections.iter().map(|sel| sel.cursor_offset()).collect();
assert_eq!(cursors, vec![1, 7]);
assert_eq!(s.undo_manager.undo_count(), 1);
s.undo(&Undo, window, cx);
assert_eq!(s.value(), "aaa bbb ccc");
s.redo(&Redo, window, cx);
assert_eq!(s.value(), "X bbb Y");
});
});
}
#[gpui::test]
fn test_ime_composition_undoes_as_one_unit(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state.default_value(""));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |s, cx| {
s.replace_and_mark_text_in_range(None, "n", Some(1..1), window, cx);
s.replace_and_mark_text_in_range(None, "ni", Some(2..2), window, cx);
s.replace_text_in_range(None, "你", window, cx);
assert_eq!(s.value(), "你");
assert_eq!(s.undo_manager.undo_count(), 1);
s.undo(&Undo, window, cx);
assert_eq!(s.value(), "");
s.redo(&Redo, window, cx);
assert_eq!(s.value(), "你");
});
});
}
#[gpui::test]
fn test_edit_after_composition_is_separate_undo(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state.default_value(""));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |s, cx| {
s.replace_and_mark_text_in_range(None, "n", Some(1..1), window, cx);
s.replace_text_in_range(None, "你", window, cx);
assert_eq!(s.value(), "你");
assert_eq!(s.undo_manager.undo_count(), 1);
s.replace_text_in_range(None, "x", window, cx);
assert_eq!(s.value(), "你x");
assert_eq!(s.undo_manager.undo_count(), 2);
s.undo(&Undo, window, cx);
assert_eq!(s.value(), "你");
s.undo(&Undo, window, cx);
assert_eq!(s.value(), "");
});
});
}
#[gpui::test]
fn test_composition_cancel_via_unmark_does_not_leak(cx: &mut TestAppContext) {
let input_view = InputView::build_textarea(cx, |state| state.default_value(""));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |s, cx| {
s.replace_and_mark_text_in_range(None, "n", Some(1..1), window, cx);
s.unmark_text(window, cx);
let after_cancel = s.undo_manager.undo_count();
s.replace_text_in_range(None, "x", window, cx);
assert_eq!(s.undo_manager.undo_count(), after_cancel + 1);
});
});
}
#[gpui::test]
fn test_set_selected_range_clips_to_utf8_boundaries(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state.default_value("éx"));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_selected_range(0..1, cx);
assert_eq!(state.selected_range(), 0..2);
state.copy(&Copy, window, cx);
state.set_selected_range(1..1, cx);
assert_eq!(state.selected_range(), 0..0);
});
});
}
#[gpui::test]
fn test_ime_selection_is_relative_to_replacement_start(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state.default_value("你好 "));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_selected_range(7..7, cx);
state.replace_and_mark_text_in_range(None, "s", Some(1..1), window, cx);
state.replace_and_mark_text_in_range(None, "sh", Some(2..2), window, cx);
assert_eq!(state.value(), "你好 sh");
assert_eq!(state.selected_range(), 9..9);
assert_eq!(state.ime_marked_range, Some((7..9).into()));
});
});
}
#[gpui::test]
fn test_undo_manager_composition_is_one_undo_group(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("a", window, cx);
state.replace_and_mark_text_in_range(None, "s", None, window, cx);
state.replace_and_mark_text_in_range(None, "sh", None, window, cx);
state.replace_text_in_range(None, "是", window, cx);
assert_eq!(state.value(), "a是");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "a");
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "a是");
});
});
}
#[gpui::test]
fn test_undo_manager_consecutive_compositions_are_separate_groups(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_and_mark_text_in_range(None, "j", None, window, cx);
state.replace_and_mark_text_in_range(None, "jin", None, window, cx);
state.replace_text_in_range(None, "今天", window, cx);
state.replace_and_mark_text_in_range(None, "w", None, window, cx);
state.replace_and_mark_text_in_range(None, "wo", None, window, cx);
state.replace_text_in_range(None, "我们", window, cx);
assert_eq!(state.value(), "今天我们");
assert_eq!(state.selected_range(), 12..12);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "今天");
assert_eq!(state.selected_range(), 6..6);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
assert_eq!(state.selected_range(), 0..0);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "今天");
assert_eq!(state.selected_range(), 6..6);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "今天我们");
assert_eq!(state.selected_range(), 12..12);
});
});
}
#[gpui::test]
fn test_undo_manager_typing_after_composition_is_a_separate_group(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_and_mark_text_in_range(None, "n", None, window, cx);
state.replace_text_in_range(None, "你", window, cx);
state.undo_manager.set_pending_intent(EditIntent::Typing);
state.replace_text_in_range(None, "a", window, cx);
state.undo_manager.set_pending_intent(EditIntent::Typing);
state.replace_text_in_range(None, "b", window, cx);
assert_eq!(state.value(), "你ab");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "你");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_composition_cancel_leaves_no_entry(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("a", window, cx);
state.replace_and_mark_text_in_range(None, "s", None, window, cx);
state.replace_and_mark_text_in_range(None, "", None, window, cx);
assert_eq!(state.value(), "a");
assert!(!state.undo_manager.has_undos());
});
});
}
#[gpui::test]
fn test_undo_manager_selection_restored_by_undo_and_redo(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("abc", window, cx);
state.set_selected_range(1..2, cx);
state.replace_text_in_range(None, "X", window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abc");
assert_eq!(state.selected_range(), 1..2);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "aXc");
assert_eq!(state.selected_range(), 2..2);
});
});
}
#[gpui::test]
fn test_undo_manager_forward_delete_restores_cursor(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("abc", window, cx);
state.set_selected_range(1..1, cx);
state.delete(&Delete, window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abc");
assert_eq!(state.selected_range(), 1..1);
});
});
}
#[gpui::test]
fn test_undo_manager_selection_movement_preserves_redo(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "ab", window, cx);
state.undo(&Undo, window, cx);
state.set_selected_range(0..0, cx);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "ab");
state.undo(&Undo, window, cx);
state.replace_text_in_range(None, "x", window, cx);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "x");
});
});
}
#[gpui::test]
fn test_undo_manager_noop_edit_preserves_redo(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.move_to(0, None, cx);
state.replace_text_in_range(None, "", window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "a");
assert_eq!(state.cursor(), 1);
});
});
}
#[gpui::test]
fn test_cursor_round_trip_stops_typing_coalescing(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.left(&MoveLeft, window, cx);
state.right(&MoveRight, window, cx);
state.replace_text_in_range(None, "b", window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "a");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_noop_edit_breaks_coalescing_without_clearing_history(
cx: &mut TestAppContext,
) {
let input_view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "alpha", window, cx);
state.replace_text_in_range(None, "", window, cx);
state.replace_text_in_range(None, "beta", window, cx);
assert_eq!(state.value(), "alphabeta");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "alpha");
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "");
});
});
}
#[gpui::test]
fn test_undo_manager_masked_redo_restores_actual_cursor(cx: &mut TestAppContext) {
let input_view = InputView::build(cx, |state| {
state.mask_pattern(MaskPattern::Number {
separator: Some(','),
fraction: None,
})
});
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("12345", window, cx);
state.set_selected_range(2..2, cx);
state.replace_text_in_range(None, "9", window, cx);
let selection_after_edit = state.selected_range();
assert_ne!(selection_after_edit.end, state.value().len());
state.undo(&Undo, window, cx);
state.redo(&Redo, window, cx);
assert_eq!(state.selected_range(), selection_after_edit);
});
});
}
#[gpui::test]
fn test_unfold_at(cx: &mut TestAppContext) {
use crate::input::{FoldRange, Position};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl", window, cx);
state.apply_highlighter_fold_candidates(
vec![
FoldRange::new(0, 5),
FoldRange::new(2, 4),
FoldRange::new(7, 10),
],
cx,
);
state.display_map.set_folded(0, true);
state.display_map.set_folded(2, true);
state.display_map.set_folded(7, true);
});
});
for line in [0, 5] {
cx.update(|_, cx| {
input.update(cx, |state, cx| {
assert!(!state.display_map.is_buffer_line_hidden(line));
assert!(!state.unfold_at(Position::new(line as u32, 0), cx));
});
input.read_with(cx, |state, _| {
assert!(state.display_map.is_folded_at(0));
assert!(state.display_map.is_folded_at(2));
assert!(state.display_map.is_folded_at(7));
});
});
}
cx.update(|_, cx| {
input.update(cx, |state, cx| {
assert!(state.unfold_at(Position::new(3, 0), cx));
});
input.read_with(cx, |state, _| {
assert!(!state.display_map.is_buffer_line_hidden(3));
assert!(!state.display_map.is_folded_at(0));
assert!(!state.display_map.is_folded_at(2));
assert!(state.display_map.is_folded_at(7));
assert!(state.display_map.is_fold_candidate(0));
assert!(state.display_map.is_fold_candidate(2));
});
});
cx.update(|_, cx| {
input.update(cx, |state, cx| {
assert!(!state.unfold_at(Position::new(3, 0), cx));
});
});
}
#[gpui::test]
fn test_blur_keeps_decorations(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("select 1", window, cx);
let _collection = state.create_decorations_collection(
vec![crate::input::TextDecoration::new(
0..6,
gpui::HighlightStyle {
font_weight: Some(gpui::FontWeight::BOLD),
..Default::default()
},
)],
cx,
);
state.present_hover(
0..6,
lsp_types::Hover {
contents: lsp_types::HoverContents::Scalar(
lsp_types::MarkedString::String("docs".into()),
),
range: None,
},
cx,
);
assert!(state.hover_popover().is_some());
state.on_blur(window, cx);
assert!(
state.hover_popover().is_none(),
"blur should hide the hover popover"
);
let decorations = state.extras.decoration_layers();
assert!(
decorations.iter().any(|layer| !layer.is_empty()),
"blur must not discard decorations"
);
});
});
}
#[gpui::test]
fn test_kind_does_not_follow_the_row_count(cx: &mut TestAppContext) {
let view = InputView::build_textarea(cx, |state| state.auto_grow(1, 1));
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
view.input.read_with(&mut cx, |state, _| {
assert!(state.is_multi_line());
assert!(!state.is_single_line());
assert!(!state.is_code_editor());
});
}
#[gpui::test]
fn test_soft_wrap_is_enabled_by_default(cx: &mut TestAppContext) {
let textarea = InputView::build_textarea(cx, |state| state);
let mut textarea_cx = VisualTestContext::from_window(textarea.window_handle.into(), cx);
textarea
.input
.read_with(&mut textarea_cx, |state, _| assert!(state.soft_wrap));
let editor = InputView::<EditorMode>::new(cx);
let mut editor_cx = VisualTestContext::from_window(editor.window_handle.into(), cx);
editor
.input
.read_with(&mut editor_cx, |state, _| assert!(state.soft_wrap));
}
fn parse_cursor_spec(input: &str) -> (String, Vec<usize>) {
let mut full_text = String::new();
let mut cursor_offsets = Vec::new();
let non_empty_lines: Vec<&str> = input.lines().filter(|l| !l.is_empty()).collect();
for (line_idx, line) in non_empty_lines.iter().enumerate() {
let mut positions = Vec::new();
let mut text = String::new();
for ch in line.chars() {
if ch == '|' {
positions.push(text.len());
} else {
text.push(ch);
}
}
if line_idx > 0 {
full_text.push('\n');
}
let line_start = full_text.len();
for pos in positions {
cursor_offsets.push(line_start + pos);
}
full_text.push_str(&text);
}
full_text.push('\n');
(full_text, cursor_offsets)
}
fn multi_line(cx: &mut TestAppContext) -> InputView<TextareaMode> {
InputView::build_textarea(cx, |state| state)
}
fn setup_cursors<M: InputModeKind>(
cx: &mut VisualTestContext,
input: &Entity<InputBaseState<M>>,
spec: &str,
) {
let (full_text, offsets) = parse_cursor_spec(spec);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value(&full_text, window, cx);
let selections = offsets
.into_iter()
.map(|offset| {
CursorSelection::new(state.selections.generate_id(), offset, offset)
})
.collect();
state.selections.replace_all(selections);
cx.notify();
});
});
}
#[track_caller]
fn assert_cursors<M: InputModeKind>(
cx: &mut VisualTestContext,
input: &Entity<InputBaseState<M>>,
spec: &str,
) {
let (expected_text, mut expected_cursors) = parse_cursor_spec(spec);
expected_cursors.sort();
let (actual_text, mut actual_cursors) = input.read_with(cx, |state, _| {
(
state.text.to_string(),
state
.selections
.iter()
.map(|s| s.cursor_offset())
.collect::<Vec<_>>(),
)
});
actual_cursors.sort();
assert_eq!(
actual_text, expected_text,
"Text mismatch:\nExpected: {expected_text:?}\nActual: {actual_text:?}"
);
assert_eq!(
actual_cursors, expected_cursors,
"Cursor mismatch:\nExpected: {expected_cursors:?}\nActual: {actual_cursors:?}"
);
}
#[gpui::test]
fn test_alt_drag_selects_a_block_and_replaces_each_row(cx: &mut TestAppContext) {
cx.update(crate::init);
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
for modifiers in [
gpui::Modifiers {
alt: true,
..Default::default()
},
gpui::Modifiers {
alt: true,
shift: true,
..Default::default()
},
#[cfg(target_os = "linux")]
gpui::Modifiers {
alt: true,
control: true,
..Default::default()
},
] {
setup_cursors(&mut cx, &view.input, "|abcd\nabcd\nabcd");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
let (start, end) = view.input.read_with(&cx, |state, _| {
let layout = state.last_layout.as_ref().unwrap();
let origin = state.last_bounds.unwrap().origin;
let position = |row: usize, col| {
let local = layout.lines[row]
.position_for_index(col, layout, false)
.unwrap();
origin
+ point(
layout.line_number_width + local.x,
layout.line_height * (row as f32 + 0.5),
)
};
(position(0, 1), position(2, 3))
});
cx.update(|_, cx| {
view.input.update(cx, |state, _| {
state.extras.hover_definition.update(
0..4,
vec![lsp_types::LocationLink {
origin_selection_range: None,
target_uri: "file:///tmp/column-selection.rs".parse().unwrap(),
target_range: Default::default(),
target_selection_range: Default::default(),
}],
);
});
});
cx.simulate_mouse_down(start, MouseButton::Left, modifiers);
cx.simulate_mouse_move(end, MouseButton::Left, modifiers);
cx.simulate_mouse_up(end, MouseButton::Left, modifiers);
view.input.read_with(&cx, |state, _| {
let ranges: Vec<_> = state
.selections
.iter()
.map(|sel| sel.start..sel.end)
.collect();
assert_eq!(ranges, vec![1..3, 6..8, 11..13]);
});
cx.simulate_mouse_move(start, None, modifiers);
cx.simulate_keystrokes("x");
assert_cursors(&mut cx, &view.input, "ax|d\nax|d\nax|d");
}
}
#[gpui::test]
fn test_alt_drag_extends_upward_from_an_existing_cursor(cx: &mut TestAppContext) {
cx.update(crate::init);
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "abcd\nabcd\na|bcd");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
let (start, end) = view.input.read_with(&cx, |state, _| {
let layout = state.last_layout.as_ref().unwrap();
let origin = state.last_bounds.unwrap().origin;
let local = layout.lines[0]
.position_for_index(1, layout, false)
.unwrap();
let x = layout.line_number_width + local.x;
(
origin + point(x, layout.line_height * 2.5),
origin + point(x, layout.line_height * 0.5),
)
});
let modifiers = gpui::Modifiers {
alt: true,
..Default::default()
};
cx.simulate_mouse_down(start, MouseButton::Left, modifiers);
cx.simulate_mouse_move(end, MouseButton::Left, modifiers);
cx.simulate_mouse_up(end, MouseButton::Left, modifiers);
assert_cursors(&mut cx, &view.input, "a|bcd\na|bcd\na|bcd");
}
#[gpui::test]
fn test_alt_drag_over_a_short_row_keeps_the_block_width(cx: &mut TestAppContext) {
cx.update(crate::init);
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "abcdef\nab\nabcdef");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
let (start, end) = view.input.read_with(&cx, |state, _| {
let layout = state.last_layout.as_ref().unwrap();
let origin = state.last_bounds.unwrap().origin;
let x_of = |index| {
layout.line_number_width
+ layout.lines[0]
.position_for_index(index, layout, false)
.unwrap()
.x
};
(
origin + point(x_of(1), layout.line_height * 0.5),
origin + point(x_of(5), layout.line_height * 1.5),
)
});
let modifiers = gpui::Modifiers {
alt: true,
shift: true,
..Default::default()
};
cx.simulate_mouse_down(start, MouseButton::Left, modifiers);
cx.simulate_mouse_move(end, MouseButton::Left, modifiers);
cx.simulate_mouse_up(end, MouseButton::Left, modifiers);
view.input.read_with(&cx, |state, _| {
let ranges: Vec<_> = state.selections.iter().map(|s| (s.start, s.end)).collect();
assert_eq!(ranges, vec![(1, 5), (8, 9)]);
});
}
#[gpui::test]
fn test_alt_mouse_release_outside_editor_ends_column_selection(cx: &mut TestAppContext) {
cx.update(crate::init);
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|abcd\nabcd");
let position = view.input.read_with(&cx, |state, _| {
let layout = state.last_layout.as_ref().unwrap();
state.last_bounds.unwrap().origin
+ point(layout.line_number_width + px(2.), layout.line_height * 0.5)
});
let modifiers = gpui::Modifiers {
alt: true,
..Default::default()
};
cx.simulate_mouse_down(position, MouseButton::Left, modifiers);
cx.simulate_mouse_up(point(px(-100.), px(-100.)), MouseButton::Left, modifiers);
view.input.read_with(&cx, |state, _| {
assert!(!state.selecting);
assert!(state.column_select_start.is_none());
});
}
#[gpui::test]
fn test_consumed_keystrokes_keep_cursor_visible(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "a|b");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.focus(window, cx);
state.pause_blink_cursor(cx);
});
});
cx.run_until_parked();
cx.executor()
.advance_clock(std::time::Duration::from_millis(300));
cx.run_until_parked();
view.input.read_with(&cx, |state, cx| {
assert!(!state.blink_cursor.read(cx).visible());
});
for _ in 0..5 {
#[cfg(target_os = "macos")]
cx.simulate_keystrokes("cmd-c");
#[cfg(not(target_os = "macos"))]
cx.simulate_keystrokes("ctrl-c");
cx.run_until_parked();
cx.executor()
.advance_clock(std::time::Duration::from_millis(200));
cx.run_until_parked();
view.input.read_with(&cx, |state, cx| {
assert!(state.blink_cursor.read(cx).visible());
});
}
}
#[gpui::test]
fn test_multi_cursor_actions_reveal_hidden_carets(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "ab\na|b\nab");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
for action in 0..6 {
state.blink_cursor = cx.new(|_| BlinkCursor::new());
assert!(!state.blink_cursor.read(cx).visible());
match action {
0 => state.add_cursor_above(&AddCursorAbove, window, cx),
1 => state.add_cursor_below(&AddCursorBelow, window, cx),
2 => state.select_up(&SelectUp, window, cx),
3 => state.select_down(&SelectDown, window, cx),
4 => state.replace_text_in_range(None, "x", window, cx),
_ => state.backspace(&Backspace, window, cx),
}
assert!(state.blink_cursor.read(cx).visible(), "action {action}");
}
});
});
}
#[gpui::test]
fn test_multi_cursor_keyboard_dispatch(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "ab\na|b\nab");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
#[cfg(target_os = "macos")]
cx.simulate_keystrokes("cmd-alt-up");
#[cfg(target_os = "windows")]
cx.simulate_keystrokes("ctrl-alt-up");
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
cx.simulate_keystrokes("alt-shift-up");
view.input
.read_with(&cx, |state, _| assert_eq!(state.selections.len(), 2));
#[cfg(target_os = "macos")]
cx.simulate_keystrokes("cmd-alt-down");
#[cfg(target_os = "windows")]
cx.simulate_keystrokes("ctrl-alt-down");
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
cx.simulate_keystrokes("alt-shift-down");
view.input
.read_with(&cx, |state, _| assert_eq!(state.selections.len(), 3));
cx.simulate_keystrokes("x");
assert_cursors(&mut cx, &view.input, "ax|b\nax|b\nax|b");
}
#[gpui::test]
fn test_multi_cursor_platform_word_selection_dispatch(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "one |two\none |two");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
#[cfg(any(target_os = "macos", target_os = "linux"))]
cx.simulate_keystrokes("alt-shift-right");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
cx.simulate_keystrokes("ctrl-shift-right");
cx.simulate_keystrokes("x");
assert_cursors(&mut cx, &view.input, "one x|\none x|");
setup_cursors(&mut cx, &view.input, "one two|\none two|");
#[cfg(any(target_os = "macos", target_os = "linux"))]
cx.simulate_keystrokes("alt-shift-left");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
cx.simulate_keystrokes("ctrl-shift-left");
cx.simulate_keystrokes("x");
assert_cursors(&mut cx, &view.input, "one x|\none x|");
}
#[cfg(not(target_os = "macos"))]
#[gpui::test]
fn test_multi_cursor_horizontal_selection_dispatch(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "ab\na|b");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
#[cfg(target_os = "windows")]
cx.simulate_keystrokes("ctrl-alt-up");
#[cfg(not(target_os = "windows"))]
cx.simulate_keystrokes("alt-shift-up");
#[cfg(target_os = "linux")]
cx.simulate_keystrokes("shift-right");
#[cfg(not(target_os = "linux"))]
cx.simulate_keystrokes("alt-shift-right");
view.input.read_with(&cx, |state, _| {
assert_eq!(
state
.selections
.iter()
.map(|s| s.start..s.end)
.collect::<Vec<_>>(),
vec![4..5, 1..2]
);
});
#[cfg(target_os = "linux")]
cx.simulate_keystrokes("shift-left shift-left");
#[cfg(not(target_os = "linux"))]
cx.simulate_keystrokes("alt-shift-left alt-shift-left");
view.input.read_with(&cx, |state, _| {
assert_eq!(
state
.selections
.iter()
.map(|s| s.start..s.end)
.collect::<Vec<_>>(),
vec![3..4, 0..1]
);
});
cx.simulate_keystrokes("x");
assert_cursors(&mut cx, &view.input, "x|b\nx|b");
}
#[gpui::test]
fn test_multi_cursor_alt_click_dispatch(cx: &mut TestAppContext) {
cx.update(crate::init);
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "a|b\nab\nab");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| state.focus(window, cx));
});
let position = view.input.read_with(&cx, |state, _| {
let bounds = state.last_bounds.unwrap();
let layout = state.last_layout.as_ref().unwrap();
bounds.origin + point(layout.line_number_width + px(2.), layout.line_height * 1.5)
});
cx.simulate_click(
position,
gpui::Modifiers {
alt: true,
..Default::default()
},
);
view.input
.read_with(&cx, |state, _| assert_eq!(state.selections.len(), 2));
}
#[gpui::test]
fn test_word_delete_undo_restores_caret(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "hello|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.delete_previous_word(&DeleteToPreviousWordStart, window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.selected_range(), 5..5);
});
});
}
#[gpui::test]
fn test_merged_delete_undo_restores_all_carets(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "a|b|c");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.backspace(&Backspace, window, cx);
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &view.input, "a|b|c");
}
#[gpui::test]
fn test_shift_end_respects_soft_wrap_end(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_value("abcdef ".repeat(30), window, cx);
state.display_map.on_layout_changed(Some(px(60.)), cx);
let line = state.display_map.line(0).unwrap();
assert!(line.wrapped_lines.len() > 1);
let boundary = line.wrapped_lines[0].end;
state.move_to_with_affinity(boundary, None, true, cx);
state.select_to_end_of_line(&SelectToEndOfLine, window, cx);
assert_eq!(state.selected_range(), boundary..state.text.len());
});
});
}
#[gpui::test]
fn test_outdent_unindented_unicode_is_unchanged(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|你好\n|世界");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.outdent(false, window, cx);
state.outdent(true, window, cx);
});
});
assert_cursors(&mut cx, &view.input, "|你好\n|世界");
}
#[gpui::test]
fn test_column_selection_stays_on_unicode_boundaries(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "ab\n你好\ncd");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.build_columnar_selection(
ColumnarPoint::new(1, 0),
ColumnarPoint::new(11, 0),
cx,
);
let text = state.value();
for sel in state.selections.iter() {
assert!(text.is_char_boundary(sel.start));
assert!(text.is_char_boundary(sel.end));
}
state.replace_text_in_range(None, "X", window, cx);
});
});
}
#[gpui::test]
fn test_editor_decorations_follow_typing(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_value("abc def", window, cx);
state.create_decorations_collection(
vec![crate::input::TextDecoration::new(
4..7,
gpui::HighlightStyle::default(),
)],
cx,
);
state.set_selected_range(0..0, cx);
state.replace_text_in_range(None, "X", window, cx);
let layers = state.extras.decoration_layers();
assert_eq!(layers.into_iter().flatten().next().unwrap().range, 5..8);
});
});
}
#[gpui::test]
fn test_backspace_preserves_escaped_quote_terminator(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, r#"{"s":"\"|"}"#);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(StringAllProvider), cx);
state.backspace(&Backspace, window, cx);
});
});
assert_cursors(&mut cx, &view.input, r#"{"s":"\|"}"#);
}
#[gpui::test]
fn test_ordinary_typing_does_not_query_syntax(cx: &mut TestAppContext) {
struct UnexpectedQuery;
impl crate::input::SyntaxContextProvider for UnexpectedQuery {
fn context_at(&self, _: &ropey::Rope, _: usize) -> crate::input::SyntaxContext {
panic!("ordinary typing must not query syntax");
}
}
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(UnexpectedQuery), cx);
for c in ["a", "b", "c"] {
state.replace_text_in_range(None, c, window, cx);
}
});
});
assert_cursors(&mut cx, &view.input, "abc|");
}
#[gpui::test]
fn test_auto_close_is_atomic_at_history_limit(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
for _ in 0..999 {
state.replace_text_in_range(None, "a", window, cx);
}
state.replace_text_in_range(None, "(", window, cx);
state.undo(&Undo, window, cx);
assert!(!state.text.to_string().contains('('));
assert!(!state.text.to_string().contains(')'));
state.redo(&Redo, window, cx);
});
});
assert_cursors(&mut cx, &view.input, &format!("{}(|)", "a".repeat(999)));
}
#[gpui::test]
fn test_comment_closer_is_inserted_literally(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "// (|)");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(CommentAllProvider), cx);
state.replace_text_in_range(None, ")", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "// ()|)");
}
#[gpui::test]
fn test_explicit_range_skip_does_not_delete_existing_closer_on_undo(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "(a|)");
let before = view.input.read_with(&cx, |state, _| state.text.to_string());
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(Some(2..2), ")", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(a)|");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.undo(&Undo, window, cx));
});
view.input.read_with(&cx, |state, _| {
assert_eq!(
state.text.to_string(),
before,
"skip must not remove an existing closer on undo"
);
});
}
#[gpui::test]
fn test_pair_redo_restores_interior_cursor(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "(", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|)");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.undo(&Undo, window, cx);
state.redo(&Redo, window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|)");
}
#[gpui::test]
fn test_pair_enter_redo_restores_interior_cursor(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "{\n |\n}");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.redo(&Redo, window, cx));
});
assert_cursors(&mut cx, &view.input, "{\n |\n}");
}
#[gpui::test]
fn test_auto_close_parens(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "(", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|)");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "x", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(x|)");
}
#[gpui::test]
fn test_auto_close_skip_over_closer(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "(|)");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, ")", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "()|");
view.input.read_with(&cx, |state, _| {
assert!(state.active_selection().is_empty());
});
}
#[gpui::test]
fn test_auto_close_no_pair_inside_word(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "don|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "'", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "don'|");
}
#[gpui::test]
fn test_auto_close_quote_after_cjk(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "中|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "'", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "中'|");
}
#[gpui::test]
fn test_auto_close_closer_skips_at_string_end(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "\"hello|\"");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "\"", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "\"hello\"|");
view.input.read_with(&cx, |state, _| {
assert!(state.active_selection().is_empty());
});
}
#[gpui::test]
fn test_backspace_pair_with_cjk_prefix(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "中(|)abc");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.backspace(&Backspace, window, cx));
});
assert_cursors(&mut cx, &view.input, "中|abc");
}
#[gpui::test]
fn test_enter_split_respects_smart_indent_off(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_smart_indent(false, window, cx);
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "{\n|}");
}
#[gpui::test]
fn test_enter_split_is_independent_of_auto_close(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_auto_close(false, window, cx);
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "{\n |\n}");
}
#[gpui::test]
fn test_language_config_applies_before_render_and_on_language_change(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, set_language_config};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
set_language_config(
"alpha",
LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("«", "»")]),
cx,
);
set_language_config(
"beta",
LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("‹", "›")]),
cx,
);
let first = cx.new(|cx| EditorState::new(window, cx).language("ALPHA"));
let second = cx.new(|cx| EditorState::new(window, cx).language("beta"));
first.update(cx, |state, cx| {
state.replace_text_in_range(None, "«", window, cx);
assert_eq!(state.text().to_string(), "«»");
state.set_value("", window, cx);
state.set_smart_indent(false, window, cx);
state.set_highlighter("beta", cx);
assert!(!state.mode.is_smart_indent());
state.replace_text_in_range(None, "‹", window, cx);
assert_eq!(state.text().to_string(), "‹›");
state.set_value("", window, cx);
state.set_highlighter("unknown", cx);
state.replace_text_in_range(None, "(", window, cx);
assert_eq!(state.text().to_string(), "()");
});
second.update(cx, |state, cx| {
state.replace_text_in_range(None, "‹", window, cx);
assert_eq!(state.text().to_string(), "‹›");
});
});
}
#[gpui::test]
fn test_language_service_replacement_updates_syntax_without_render(cx: &mut TestAppContext) {
use crate::input::{LanguageProvider, SyntaxContextProvider, set_language_provider};
struct StringLanguages(Rc<Cell<usize>>);
impl LanguageProvider for StringLanguages {
fn syntax_context_provider(&self, _: &str) -> Option<Rc<dyn SyntaxContextProvider>> {
self.0.set(self.0.get() + 1);
Some(Rc::new(StringAllProvider))
}
}
struct CodeLanguages;
impl LanguageProvider for CodeLanguages {}
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
let creations = Rc::new(Cell::new(0));
set_language_provider(Rc::new(StringLanguages(creations.clone())), cx);
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "(", window, cx);
state.replace_text_in_range(None, "[", window, cx);
assert_eq!(state.text().to_string(), "([");
assert_eq!(
creations.get(),
1,
"retain the document provider between edits"
);
});
set_language_provider(Rc::new(CodeLanguages), cx);
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "{", window, cx);
assert_eq!(state.text().to_string(), "([{}");
});
});
}
#[gpui::test]
fn test_language_config_updates_existing_editors(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, set_language_config};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_highlighter("CUSTOM", cx);
state.set_auto_close(false, window, cx);
state.set_smart_indent(false, window, cx);
});
set_language_config(
"custom",
LanguageConfig::default().auto_closing_pairs([AutoClosingPair::new("«", "»")]),
cx,
);
set_language_config("other", LanguageConfig::default(), cx);
view.input.update(cx, |state, cx| {
assert!(!state.mode.is_auto_close());
assert!(!state.mode.is_smart_indent());
state.set_auto_close(true, window, cx);
state.replace_text_in_range(None, "«", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "«|»");
}
#[gpui::test]
fn test_auto_close_before_is_language_configurable(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|word");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "(", window, cx)
});
});
assert_cursors(&mut cx, &view.input, "(|word");
setup_cursors(&mut cx, &view.input, "|word");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default().auto_close_before("w"),
cx,
);
state.replace_text_in_range(None, "(", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|)word");
}
#[gpui::test]
fn test_pair_context_restrictions_are_per_pair(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, SyntaxContext};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(CommentAllProvider), cx);
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default().auto_closing_pairs([
AutoClosingPair::new("(", ")").not_in([SyntaxContext::String]),
AutoClosingPair::new("[", "]").not_in([SyntaxContext::Comment]),
]),
cx,
);
state.replace_text_in_range(None, "(", window, cx);
state.replace_text_in_range(None, "[", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "([|)");
}
#[gpui::test]
fn test_multichar_delimiters_insert_delete_and_skip(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, SyntaxContext};
struct BlockComment;
impl crate::input::SyntaxContextProvider for BlockComment {
fn context_at(&self, text: &ropey::Rope, _: usize) -> SyntaxContext {
if text.to_string().contains("/*") {
SyntaxContext::Comment
} else {
SyntaxContext::Code
}
}
}
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(BlockComment), cx);
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default()
.auto_closing_pairs([AutoClosingPair::new("/*", "*/")
.not_in([SyntaxContext::String, SyntaxContext::Comment])]),
cx,
);
state.replace_text_in_range(None, "/", window, cx);
state.replace_text_in_range(None, "*", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "/*|*/");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.backspace(&Backspace, window, cx));
});
assert_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.undo(&Undo, window, cx);
state.replace_text_in_range(None, "*", window, cx);
state.replace_text_in_range(None, "/", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "/**/|");
}
#[gpui::test]
fn test_auto_closed_pairs_fallback_and_explicit_disable(cx: &mut TestAppContext) {
use crate::input::BracketPair;
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
let mut rules = LanguageConfig::default().brackets([BracketPair::new("«", "»")]);
rules.auto_closing_pairs = None;
crate::input::set_language_config(state.language_name(), rules.clone(), cx);
state.replace_text_in_range(None, "«", window, cx);
crate::input::set_language_config(
state.language_name(),
rules.auto_closing_pairs([]),
cx,
);
state.replace_text_in_range(None, "«", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "««|»");
}
#[gpui::test]
fn test_undo_open_composition_preserves_generated_pairs(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, SyntaxContext};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default()
.auto_closing_pairs([
AutoClosingPair::new("/*", "*/").not_in([SyntaxContext::Comment])
]),
cx,
);
state.replace_text_in_range(None, "/", window, cx);
state.replace_text_in_range(None, "*", window, cx);
set_test_syntax_provider(std::rc::Rc::new(CommentAllProvider), cx);
state.replace_and_mark_text_in_range(None, "x", Some(1..1), window, cx);
state.undo(&Undo, window, cx);
state.replace_text_in_range(None, "*", window, cx);
state.replace_text_in_range(None, "/", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "/**/|");
}
#[gpui::test]
fn test_undo_does_not_promote_literal_comment_delimiters(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, SyntaxContext};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "/*|*/");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(CommentAllProvider), cx);
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default()
.auto_closing_pairs([
AutoClosingPair::new("/*", "*/").not_in([SyntaxContext::Comment])
]),
cx,
);
state.delete_to_end_of_line(&DeleteToEndOfLine, window, cx);
state.undo(&Undo, window, cx);
state.backspace(&Backspace, window, cx);
});
});
assert_cursors(&mut cx, &view.input, "/|*/");
}
#[gpui::test]
fn test_multiple_generated_pairs_retain_their_identity(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, SyntaxContext};
struct CommentScope;
impl crate::input::SyntaxContextProvider for CommentScope {
fn context_at(&self, text: &ropey::Rope, offset: usize) -> SyntaxContext {
if text
.to_string()
.find("*/")
.is_some_and(|end| offset < end + 2)
{
SyntaxContext::Comment
} else {
SyntaxContext::Code
}
}
}
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
set_test_syntax_provider(std::rc::Rc::new(CommentScope), cx);
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default().auto_closing_pairs([
AutoClosingPair::new("/*", "*/").not_in([SyntaxContext::Comment]),
AutoClosingPair::new("(", ")").not_in([SyntaxContext::Comment]),
]),
cx,
);
state.replace_text_in_range(None, "/", window, cx);
state.replace_text_in_range(None, "*", window, cx);
state.set_selected_range(4..4, cx);
state.replace_text_in_range(None, "(", window, cx);
state.set_selected_range(2..2, cx);
state.replace_text_in_range(None, "*", window, cx);
state.replace_text_in_range(None, "/", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "/**/|()");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_selected_range(2..2, cx);
state.backspace(&Backspace, window, cx);
state.undo(&Undo, window, cx);
state.redo(&Redo, window, cx);
});
});
assert_cursors(&mut cx, &view.input, "|()");
}
#[gpui::test]
fn test_replacing_generated_opener_invalidates_the_pair(cx: &mut TestAppContext) {
use crate::input::{AutoClosingPair, SyntaxContext};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default()
.auto_closing_pairs([
AutoClosingPair::new("/*", "*/").not_in([SyntaxContext::Comment])
]),
cx,
);
state.replace_text_in_range(None, "/", window, cx);
state.replace_text_in_range(None, "*", window, cx);
state.replace_text_in_range(Some(0..2), "//", window, cx);
set_test_syntax_provider(std::rc::Rc::new(CommentAllProvider), cx);
state.replace_text_in_range(None, "*", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "//*|*/");
}
#[gpui::test]
fn test_configuration_preserves_compiled_regex_options(cx: &mut TestAppContext) {
use crate::input::IndentationRules;
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "BEGIN|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
let rules = |insensitive| {
LanguageConfig::default().indentation_rules(IndentationRules::new(
regex::RegexBuilder::new("begin$")
.case_insensitive(insensitive)
.build()
.unwrap(),
regex::Regex::new("^end").unwrap(),
))
};
crate::input::set_language_config(state.language_name(), rules(false), cx);
crate::input::set_language_config(state.language_name(), rules(true), cx);
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "BEGIN\n |");
}
#[gpui::test]
fn test_indent_patterns_are_applied_on_enter(cx: &mut TestAppContext) {
use crate::input::IndentationRules;
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let rules = LanguageConfig::default().indentation_rules(IndentationRules::new(
regex::Regex::new(r"\bbegin\s*$").unwrap(),
regex::Regex::new(r"^\s*end\b").unwrap(),
));
for (before, expected) in [("begin|", "begin\n |"), (" |end", " \n|end")] {
setup_cursors(&mut cx, &view.input, before);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
crate::input::set_language_config(state.language_name(), rules.clone(), cx);
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, expected);
}
}
#[gpui::test]
fn test_custom_language_config(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|_window, cx| {
view.input.update(cx, |state, cx| {
crate::input::set_language_config(
state.language_name(),
crate::input::language_config::LanguageConfig::default()
.brackets([crate::input::BracketPair::new("«", "»")])
.auto_closing_pairs([crate::input::AutoClosingPair::new("«", "»")]),
cx,
);
});
});
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "«", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "«|»");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_value("\n", window, cx);
state.set_selected_range(0..0, cx);
state.replace_text_in_range(None, "(", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|");
}
#[gpui::test]
fn test_skip_records_no_history(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "(a|)");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "b", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(ab|)");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, ")", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(ab)|");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.undo(&Undo, window, cx));
});
assert_cursors(&mut cx, &view.input, "(a|)");
}
#[gpui::test]
fn test_pair_insert_undo_removes_both(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "(", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|)");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.undo(&Undo, window, cx));
});
assert_cursors(&mut cx, &view.input, "|");
}
#[gpui::test]
fn test_escaped_quote_does_not_skip(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "\"hello\\|\"");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "\"", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "\"hello\\\"|\"");
}
#[gpui::test]
fn test_even_backslashes_still_skip(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "\"hello\\\\|\"");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "\"", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "\"hello\\\\\"|");
}
#[gpui::test]
fn test_editor_options_survive_configuration_replacement(cx: &mut TestAppContext) {
use crate::input::{BracketPair, LanguageConfig};
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_smart_indent(false, window, cx);
crate::input::set_language_config(
state.language_name(),
LanguageConfig::default().brackets([BracketPair::new("«", "»")]),
cx,
);
});
});
view.input.read_with(&cx, |state, _| {
assert!(!state.mode.is_smart_indent(), "editor option preserved");
assert!(state.mode.is_auto_close());
let pairs = &state.mode.language_config().unwrap().brackets;
assert_eq!(pairs.len(), 1, "pairs follow configuration");
assert_eq!(pairs[0].open.as_ref(), "«");
});
}
struct CommentAllProvider;
impl crate::input::SyntaxContextProvider for CommentAllProvider {
fn context_at(&self, _text: &ropey::Rope, _offset: usize) -> crate::input::SyntaxContext {
crate::input::SyntaxContext::Comment
}
}
struct StringAllProvider;
impl crate::input::SyntaxContextProvider for StringAllProvider {
fn context_at(&self, _text: &ropey::Rope, _offset: usize) -> crate::input::SyntaxContext {
crate::input::SyntaxContext::String
}
}
#[gpui::test]
fn test_smart_indent_suppressed_in_strings(cx: &mut TestAppContext) {
use std::rc::Rc;
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|_window, cx| {
set_test_syntax_provider(Rc::new(StringAllProvider), cx);
});
setup_cursors(&mut cx, &view.input, " x = {|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, " x = {\n |");
}
#[gpui::test]
fn test_split_suppressed_in_strings(cx: &mut TestAppContext) {
use std::rc::Rc;
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|_window, cx| {
set_test_syntax_provider(Rc::new(StringAllProvider), cx);
});
setup_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "{\n|}");
}
#[gpui::test]
fn test_comment_context_disables_pairing(cx: &mut TestAppContext) {
use std::rc::Rc;
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|_window, cx| {
set_test_syntax_provider(Rc::new(CommentAllProvider), cx);
});
setup_cursors(&mut cx, &view.input, "|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "(", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "(|");
}
#[gpui::test]
fn test_backspace_deletes_empty_pair(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.backspace(&Backspace, window, cx));
});
assert_cursors(&mut cx, &view.input, "|");
}
#[gpui::test]
fn test_backspace_keeps_closer_with_content(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "{d|}");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.backspace(&Backspace, window, cx));
});
assert_cursors(&mut cx, &view.input, "{|}");
}
#[gpui::test]
fn test_enter_splits_brackets(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "{|}");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "{\n |\n}");
view.input.read_with(&cx, |state, _| {
assert!(state.active_selection().is_empty());
});
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.replace_text_in_range(None, "x", window, cx);
});
});
assert_cursors(&mut cx, &view.input, "{\n x|\n}");
}
#[gpui::test]
fn test_enter_smart_indent_after_brace(cx: &mut TestAppContext) {
let view = InputView::<EditorMode>::new(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "fn main() {|");
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.enter(
&Enter {
secondary: false,
shift: false,
},
window,
cx,
);
});
});
assert_cursors(&mut cx, &view.input, "fn main() {\n |");
}
#[gpui::test]
fn test_block_indent_tracks_all_preceding_edits(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "|ab\n|cd\n|ef");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.indent(true, window, cx));
});
assert_cursors(&mut cx, &view.input, " |ab\n |cd\n |ef");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.outdent(true, window, cx));
});
assert_cursors(&mut cx, &view.input, "|ab\n|cd\n|ef");
}
#[gpui::test]
fn test_block_outdent_clamps_cursor_inside_indent(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
setup_cursors(&mut cx, &view.input, "ab\n | cd");
cx.update(|window, cx| {
view.input
.update(cx, |state, cx| state.outdent(true, window, cx));
});
assert_cursors(&mut cx, &view.input, "ab\n|cd");
}
#[gpui::test]
fn test_ime_restores_original_selection(cx: &mut TestAppContext) {
let view = InputView::build(cx, |state| state);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_value("abc", window, cx);
state.set_selected_range(1..2, cx);
state.replace_and_mark_text_in_range(None, "ni", None, window, cx);
state.replace_text_in_range(None, "你", window, cx);
state.undo(&Undo, window, cx);
assert_eq!(state.value(), "abc");
assert_eq!(state.selected_range(), 1..2);
state.redo(&Redo, window, cx);
assert_eq!(state.selected_range(), 4..4);
});
});
}
#[gpui::test]
fn test_noop_does_not_change_redo_selection(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
cx.update(|window, cx| {
view.input.update(cx, |state, cx| {
state.set_value("abc", window, cx);
state.set_selected_range(1..1, cx);
state.replace_text_in_range(None, "X", window, cx);
state.set_selected_range(0..0, cx);
state.replace_text_in_range(None, "", window, cx);
state.undo(&Undo, window, cx);
state.redo(&Redo, window, cx);
assert_eq!(state.value(), "aXbc");
assert_eq!(state.selected_range(), 2..2);
});
});
}
#[gpui::test]
fn test_multi_cursor_insert_text(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|hello |world|");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, ">>>", window, cx);
});
});
assert_cursors(&mut cx, &input, ">>>|hello >>>|world>>>|");
}
#[gpui::test]
fn test_multi_cursor_delete_backward(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|islands| cars|");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.backspace(&Backspace, window, cx);
});
});
assert_cursors(&mut cx, &input, "|island| car|");
}
#[gpui::test]
fn test_multi_cursor_delete_forward_merges(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "hello| |world");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.delete(&Delete, window, cx);
});
});
assert_cursors(&mut cx, &input, "hello|orld");
}
#[gpui::test]
fn test_multi_cursor_multiline_insert_and_delete(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
});
});
assert_cursors(&mut cx, &input, "a|1\na|2\na|3");
input.read_with(&cx, |state, _| {
assert_eq!(state.undo_manager.undo_count(), 1);
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.backspace(&Backspace, window, cx);
});
});
assert_cursors(&mut cx, &input, "|1\n|2\n|3");
}
#[gpui::test]
fn test_add_cursor_below_preserves_column(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "ab|cd\nabcd");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.add_cursor_below(&AddCursorBelow, window, cx);
});
});
assert_cursors(&mut cx, &input, "ab|cd\nab|cd");
}
#[gpui::test]
fn test_add_cursor_at_rejects_duplicates(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "he|llo");
cx.update(|_, cx| {
input.update(cx, |state, cx| {
state.add_cursor_at(2, cx);
assert_eq!(state.selections.len(), 1);
state.add_cursor_at(4, cx);
assert_eq!(state.selections.len(), 2);
});
});
}
#[gpui::test]
fn test_multi_cursor_undo_redo_restores_selections(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
});
});
assert_cursors(&mut cx, &input, "a|1\na|2\na|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.redo(&Redo, window, cx);
});
});
assert_cursors(&mut cx, &input, "a|1\na|2\na|3");
}
#[gpui::test]
fn test_multi_cursor_undo_redo_different_line_lengths(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "abc123|\nabc12345|\nabc1234567|");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
});
});
assert_cursors(&mut cx, &input, "abc123a|\nabc12345a|\nabc1234567a|");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &input, "abc123|\nabc12345|\nabc1234567|");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.redo(&Redo, window, cx);
});
});
assert_cursors(&mut cx, &input, "abc123a|\nabc12345a|\nabc1234567a|");
}
#[gpui::test]
fn test_multi_cursor_undo_multiple_inserts(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n|2\n|3");
for ch in ['a', 'b', 'c'] {
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, &ch.to_string(), window, cx);
});
});
}
assert_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
assert_eq!(state.undo_manager.undo_count(), 1);
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.redo(&Redo, window, cx);
});
});
assert_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3");
}
#[gpui::test]
fn test_multi_cursor_backspace_run_is_one_undo(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3");
for _ in 0..3 {
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.backspace(&Backspace, window, cx);
});
});
}
assert_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
assert_eq!(state.undo_manager.undo_count(), 1);
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3");
}
#[gpui::test]
fn test_adding_a_cursor_splits_the_typing_gesture(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n2\n3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "a", window, cx);
state.add_cursor_below(&AddCursorBelow, window, cx);
state.replace_text_in_range(None, "b", window, cx);
});
});
assert_cursors(&mut cx, &input, "ab|1\n2b|\n3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &input, "a|1\n2|\n3");
}
#[gpui::test]
fn test_multi_cursor_indent_is_one_undo(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.indent_inline(&IndentInline, window, cx);
assert_eq!(state.undo_manager.undo_count(), 1);
state.undo(&Undo, window, cx);
});
});
assert_cursors(&mut cx, &input, "|1\n|2\n|3");
}
#[gpui::test]
fn test_multi_cursor_indent_then_outdent_roundtrips(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.indent(false, window, cx);
});
});
assert_cursors(&mut cx, &input, " |1\n |2\n |3");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.outdent(false, window, cx);
});
});
assert_cursors(&mut cx, &input, "|1\n|2\n|3");
}
#[gpui::test]
fn test_inline_outdent_only_removes_line_indentation(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "1|2\n1|2");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.indent(false, window, cx);
});
});
assert_cursors(&mut cx, &input, "1 |2\n1 |2");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.outdent(false, window, cx);
});
});
assert_cursors(&mut cx, &input, "1 |2\n1 |2");
setup_cursors(&mut cx, &input, " 1|2\n 1|2");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.outdent(false, window, cx);
});
});
assert_cursors(&mut cx, &input, "1|2\n1|2");
}
#[gpui::test]
fn test_readonly_multi_cursor_commands_leave_state_unchanged(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "a|b\nc|d");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
let before: Vec<_> = state.selections.iter().copied().collect();
state.set_readonly(true, cx);
state.backspace(&Backspace, window, cx);
state.indent_inline(&IndentInline, window, cx);
assert_eq!(state.value(), "ab\ncd\n");
assert_eq!(state.selections.iter().copied().collect::<Vec<_>>(), before);
});
});
}
#[gpui::test]
fn test_multi_cursor_edit_preserves_the_active_cursor(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "x|\n|y");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
let mut selections: Vec<_> = state.selections.iter().copied().collect();
selections.swap(0, 1);
state.selections.replace_all(selections);
let active_id = state.active_selection().id;
state.replace_text_in_range(None, "!", window, cx);
assert_eq!(state.active_selection().id, active_id);
});
});
}
#[gpui::test]
fn test_block_indent_outdent_with_selection(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("line1\nline2\nline3", window, cx);
let id = state.selections.generate_id();
state
.selections
.replace_all(vec![CursorSelection::new(id, 0, 17)]);
cx.notify();
});
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.indent(true, window, cx);
});
});
input.read_with(&cx, |state, _| {
assert_eq!(state.text.to_string(), " line1\n line2\n line3");
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.outdent(true, window, cx);
});
});
input.read_with(&cx, |state, _| {
assert_eq!(state.text.to_string(), "line1\nline2\nline3");
});
}
#[gpui::test]
fn test_multi_cursor_word_movement(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(
&mut cx,
&input,
"on|e two three\none t|wo three\non|e two three",
);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.move_to_next_word(&MoveToNextWord, window, cx);
});
});
assert_cursors(
&mut cx,
&input,
"one| two three\none two| three\none| two three",
);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.move_to_previous_word(&MoveToPreviousWord, window, cx);
});
});
assert_cursors(
&mut cx,
&input,
"|one two three\none |two three\n|one two three",
);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.move_to_end(&MoveToEnd, window, cx);
});
});
input.read_with(&cx, |state, _| {
let cursors: Vec<usize> = state.selections.iter().map(|s| s.cursor_offset()).collect();
assert_eq!(cursors, vec![state.text.len()]);
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.move_to_start(&MoveToStart, window, cx);
});
});
input.read_with(&cx, |state, _| {
let cursors: Vec<usize> = state.selections.iter().map(|s| s.cursor_offset()).collect();
assert_eq!(cursors, vec![0]);
});
}
#[gpui::test]
fn test_multi_cursor_selection_commands(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(
&mut cx,
&input,
"on|e two three\none t|wo three\non|e two three",
);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.select_to_start_of_line(&SelectToStartOfLine, window, cx);
});
});
assert_cursors(
&mut cx,
&input,
"|one two three\n|one two three\n|one two three",
);
setup_cursors(
&mut cx,
&input,
"on|e two three\none t|wo three\non|e two three",
);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.select_to_start(&SelectToStart, window, cx);
});
});
input.read_with(&cx, |state, _| {
let cursors: Vec<usize> = state.selections.iter().map(|s| s.cursor_offset()).collect();
assert_eq!(cursors, vec![0]);
});
}
#[gpui::test]
fn test_multi_cursor_replace_selection(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|a\n|b\n|c");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.select_right(&SelectRight, window, cx);
});
});
input.read_with(&cx, |state, _| {
let ranges: Vec<_> = state.selections.iter().map(|s| (s.start, s.end)).collect();
assert_eq!(ranges, vec![(0, 1), (2, 3), (4, 5)]);
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.replace_text_in_range(None, "x", window, cx);
});
});
assert_cursors(&mut cx, &input, "x|\nx|\nx|");
}
#[gpui::test]
fn test_multi_cursor_escape_collapses(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|a\n|b\n|c");
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.escape(&Escape, window, cx);
});
});
input.read_with(&cx, |state, _| {
assert_eq!(state.selections.len(), 1);
});
}
#[gpui::test]
fn test_build_columnar_selection(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "abcd\nabcd\nabcd");
cx.update(|_, cx| {
input.update(cx, |state, cx| {
state.build_columnar_selection(
ColumnarPoint::new(1, 0),
ColumnarPoint::new(13, 0),
cx,
);
let ranges: Vec<_> = state.selections.iter().map(|s| (s.start, s.end)).collect();
assert_eq!(ranges, vec![(1, 3), (6, 8), (11, 13)]);
});
});
}
#[gpui::test]
fn test_columnar_selection_keeps_width_over_short_rows(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "abcdef\nab\nabcdef");
cx.update(|_, cx| {
input.update(cx, |state, cx| {
state.build_columnar_selection(
ColumnarPoint::new(1, 0),
ColumnarPoint::new(9, 3),
cx,
);
let ranges: Vec<_> = state.selections.iter().map(|s| (s.start, s.end)).collect();
assert_eq!(ranges, vec![(1, 5), (8, 9)]);
});
});
}
#[gpui::test]
fn test_multi_cursor_paste_distributes_lines(cx: &mut TestAppContext) {
let view = multi_line(cx);
let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx);
let input = view.input;
setup_cursors(&mut cx, &input, "|1\n|2\n|3");
cx.update(|_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string("x\ny\nz".to_string()));
});
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.paste(&Paste, window, cx);
});
});
assert_cursors(&mut cx, &input, "x|1\ny|2\nz|3");
}
}
impl InputBaseState<crate::input::InputMode> {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_in_mode(window, cx)
}
pub fn step_by(mut self, f: impl Fn(f64, StepAction, &mut App) -> f64 + 'static) -> Self {
self.number_step = Some(NumberStep::by_value(f));
self
}
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context<Self>) {
self.masked = masked;
cx.notify();
}
pub fn pattern(mut self, pattern: regex::Regex) -> Self {
self.pattern = Some(pattern);
self
}
pub fn set_pattern(
&mut self,
pattern: regex::Regex,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
self.pattern = Some(pattern);
}
pub fn validate(mut self, f: impl Fn(&str, &mut App) -> bool + 'static) -> Self {
self.validate = Some(Box::new(f));
self
}
pub fn set_validator(
&mut self,
validate: impl Fn(&str, &mut App) -> bool + 'static,
_cx: &mut Context<Self>,
) {
self.validate = Some(Box::new(validate));
}
pub fn step(mut self, step: impl Into<NumberStep>) -> Self {
self.number_step = Some(step.into());
self
}
pub fn min(mut self, min: f64) -> Self {
self.number_min = Some(min);
self
}
pub fn max(mut self, max: f64) -> Self {
self.number_max = Some(max);
self
}
pub fn set_step(
&mut self,
step: impl Into<Option<NumberStep>>,
_: &mut Window,
_: &mut Context<Self>,
) {
self.number_step = step.into();
}
pub fn set_min(&mut self, min: Option<f64>, _: &mut Window, _: &mut Context<Self>) {
self.number_min = min;
}
pub fn set_max(&mut self, max: Option<f64>, _: &mut Window, _: &mut Context<Self>) {
self.number_max = max;
}
pub fn set_loading(&mut self, loading: bool, _: &mut Window, cx: &mut Context<Self>) {
self.loading = loading;
cx.notify();
}
}
impl<M: crate::input::MultiLineMode> InputBaseState<M> {
#[doc(hidden)]
pub fn searchable(mut self, searchable: bool) -> Self {
self.searchable = searchable;
self
}
pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context<Self>) {
self.searchable = searchable;
cx.notify();
}
#[doc(hidden)]
pub fn soft_wrap(mut self, wrap: bool) -> Self {
self.soft_wrap = wrap;
self
}
pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context<Self>) {
self.soft_wrap = wrap;
if wrap {
let wrap_width = self
.last_layout
.as_ref()
.and_then(|b| b.wrap_width)
.unwrap_or(self.input_bounds.size.width);
self.display_map.on_layout_changed(Some(wrap_width), cx);
let mut offset = self.scroll_handle.offset();
offset.x = px(0.);
self.scroll_handle.set_offset(offset);
} else {
self.display_map.on_layout_changed(None, cx);
}
cx.notify();
}
#[doc(hidden)]
pub fn wrapping_indent(mut self, wrapping_indent: WrappingIndent) -> Self {
self.wrapping_indent = wrapping_indent;
self
}
pub fn set_wrapping_indent(
&mut self,
wrapping_indent: WrappingIndent,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.wrapping_indent = wrapping_indent;
self.display_map.set_wrapping_indent(wrapping_indent, cx);
cx.notify();
}
}
impl InputBaseState<crate::input::TextareaMode> {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_in_mode(window, cx)
}
pub fn set_auto_grow(&mut self, min_rows: usize, max_rows: usize, cx: &mut Context<Self>) {
self.mode = LayoutMode::auto_grow(min_rows, max_rows.max(min_rows));
cx.notify();
}
#[doc(hidden)]
pub fn rows(mut self, rows: usize) -> Self {
match &mut self.mode {
LayoutMode::PlainText { rows: r, .. } | LayoutMode::CodeEditor { rows: r, .. } => {
*r = rows
}
LayoutMode::AutoGrow {
max_rows: max_r,
rows: r,
..
} => {
*r = rows;
*max_r = rows;
}
}
self
}
pub fn set_rows(&mut self, rows: usize, cx: &mut Context<Self>) {
match &mut self.mode {
LayoutMode::PlainText { rows: value, .. }
| LayoutMode::CodeEditor { rows: value, .. } => *value = rows,
LayoutMode::AutoGrow {
rows: value,
max_rows,
..
} => {
*value = rows;
*max_rows = rows;
}
}
cx.notify();
}
pub fn auto_grow(mut self, min_rows: usize, max_rows: usize) -> Self {
self.mode = LayoutMode::auto_grow(min_rows, max_rows);
self
}
}
impl InputBaseState<crate::input::EditorMode> {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut state = Self::new_in_mode(window, cx);
state.mode = LayoutMode::code_editor(super::EditorLanguage::new(cx));
state.searchable = true;
state
}
pub fn language(mut self, language: impl Into<SharedString>) -> Self {
if let LayoutMode::CodeEditor {
language: l,
highlighter,
..
} = &mut self.mode
{
l.set_name(language.into());
*highlighter.borrow_mut() = None;
}
self
}
pub fn language_name(&self) -> SharedString {
match &self.mode {
LayoutMode::CodeEditor { language, .. } => language.name(),
_ => SharedString::default(),
}
}
#[doc(hidden)]
pub fn folding(mut self, folding: bool) -> Self {
if let LayoutMode::CodeEditor { folding: f, .. } = &mut self.mode {
*f = folding;
}
self
}
pub fn set_folding(&mut self, folding: bool, _: &mut Window, cx: &mut Context<Self>) {
if let LayoutMode::CodeEditor { folding: f, .. } = &mut self.mode {
*f = folding;
}
if !folding {
self.display_map.clear_folds();
}
cx.notify();
}
pub fn unfold_at(&mut self, position: impl Into<Position>, cx: &mut Context<Self>) -> bool {
let offset = self.text.position_to_offset(&position.into());
let line = self.text.offset_to_point(offset).row;
let covering: Vec<usize> = self
.display_map
.folded_ranges()
.iter()
.filter(|fold| line > fold.start_line && line < fold.end_line)
.map(|fold| fold.start_line)
.collect();
if covering.is_empty() {
return false;
}
for start_line in covering {
self.display_map.set_folded(start_line, false);
}
cx.notify();
true
}
#[doc(hidden)]
pub fn line_number(mut self, line_number: bool) -> Self {
if let LayoutMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
self
}
pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context<Self>) {
if let LayoutMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
cx.notify();
}
#[doc(hidden)]
pub fn auto_close(mut self, auto_close: bool) -> Self {
self.mode.set_auto_close(auto_close);
self
}
pub fn set_auto_close(&mut self, auto_close: bool, _: &mut Window, cx: &mut Context<Self>) {
self.mode.set_auto_close(auto_close);
cx.notify();
}
#[doc(hidden)]
pub fn smart_indent(mut self, smart_indent: bool) -> Self {
self.mode.set_smart_indent(smart_indent);
self
}
pub fn set_smart_indent(&mut self, smart_indent: bool, _: &mut Window, cx: &mut Context<Self>) {
self.mode.set_smart_indent(smart_indent);
cx.notify();
}
}