use editor::AppExt as _;
use std::sync::Mutex;
use editor::{Editor, ImageStore, Source};
use gpui::{
App, ClipboardEntry, ClipboardItem, ClipboardString, Context, Entity, EntityId, ExternalPaths,
Focusable, Render, ScrollHandle, TestAppContext, VisualTestContext, Window, WindowHandle,
point, prelude::*, px, size,
};
const SOURCE: &str = "# Title\n\nA paragraph long enough that it has to wrap more than once inside the pane it is painted into, which is what makes it worth testing.\n\n- first\n- second\n\n> a quote";
fn open(cx: &mut TestAppContext) -> (Entity<Editor>, WindowHandle<Editor>, VisualTestContext) {
open_with(SOURCE, cx)
}
fn open_with(
source: &str,
cx: &mut TestAppContext,
) -> (Entity<Editor>, WindowHandle<Editor>, VisualTestContext) {
cx.update(|cx| {
theme::Theme::install(theme::Appearance::Dark, cx);
editor::init(cx);
});
let window = cx.add_window(|_, cx| Editor::new(source, cx));
let editor = window.root(cx).unwrap();
let mut visual = VisualTestContext::from_window(window.into(), cx);
visual.simulate_resize(size(px(360.0), px(600.0)));
visual.update(|window, cx| {
let handle = editor.read(cx).focus_handle(cx);
window.focus(&handle, cx);
});
visual.run_until_parked();
(editor, window, visual)
}
struct ScrollingEditor {
editor: Entity<Editor>,
scroll: ScrollHandle,
}
impl Render for ScrollingEditor {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
ui::scroll::pane("editor-test", ui::scroll::Axes::Vertical)
.size_full()
.track_scroll(&self.scroll)
.child(self.editor.clone())
}
}
fn open_scrolling_with(
source: &str,
cx: &mut TestAppContext,
) -> (Entity<Editor>, VisualTestContext) {
cx.update(|cx| {
theme::Theme::install(theme::Appearance::Dark, cx);
editor::init(cx);
});
let window = cx.add_window(|_, cx| {
let scroll = ScrollHandle::new();
let editor = cx.new({
let scroll = scroll.clone();
|cx| Editor::new(source, cx).with_scroll(scroll)
});
ScrollingEditor { editor, scroll }
});
let host = window.root(cx).unwrap();
let editor = cx.update(|cx| host.read(cx).editor.clone());
let mut visual = VisualTestContext::from_window(window.into(), cx);
visual.simulate_resize(size(px(360.0), px(70.0)));
visual.update(|window, cx| {
let handle = editor.read(cx).focus_handle(cx);
window.focus(&handle, cx);
});
visual.run_until_parked();
(editor, visual)
}
fn head(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> markdown::Cursor {
cx.update(|_, cx| editor.read(cx).selection().head)
}
fn caret(
editor: &Entity<Editor>,
cx: &mut VisualTestContext,
) -> (gpui::Point<gpui::Pixels>, gpui::Pixels) {
cx.run_until_parked();
cx.update(|_, cx| {
let editor = editor.read(cx);
let selection = editor.selection();
editor
.layouts()
.position(selection.head, selection.affinity)
.expect("the caret painted")
})
}
fn source(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> String {
cx.update(|_, cx| editor.read(cx).source())
}
fn go_to_block(editor: &Entity<Editor>, cx: &mut VisualTestContext, block: usize) {
for _ in 0..20 {
if head(editor, cx).block == block {
return;
}
cx.simulate_keystrokes("down");
}
panic!("never reached block {block}");
}
#[gpui::test]
fn typing_reaches_the_document(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.simulate_input("Xy");
assert!(
source(&editor, &mut cx).starts_with("# XyTitle"),
"typed text lands at the caret"
);
}
#[gpui::test]
fn enter_splits_and_backspace_merges(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.simulate_keystrokes("right right enter");
assert!(
source(&editor, &mut cx).starts_with("# Ti\n\ntle"),
"a heading splits into a heading and body text"
);
cx.simulate_keystrokes("backspace");
assert!(
source(&editor, &mut cx).starts_with("# Title"),
"and backspace at the seam puts it back"
);
}
#[gpui::test]
fn shift_enter_inserts_a_soft_newline(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("hello", cx);
cx.simulate_keystrokes("right right shift-enter");
assert_eq!(source(&editor, &mut cx), "he\nllo");
}
#[gpui::test]
fn ctrl_enter_inserts_a_paragraph_after_the_current_block(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("one\n\ntwo", cx);
cx.simulate_keystrokes("end ctrl-enter");
cx.simulate_input("middle");
assert_eq!(source(&editor, &mut cx), "one\n\nmiddle\n\ntwo");
}
#[gpui::test]
fn enter_after_a_quote_stays_in_it_and_enter_again_leaves(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("> quote", cx);
cx.simulate_keystrokes("end enter");
cx.simulate_input("more");
assert_eq!(source(&editor, &mut cx), "> quote\n>\n> more");
cx.simulate_keystrokes("enter enter");
cx.simulate_input("plain");
assert_eq!(source(&editor, &mut cx), "> quote\n>\n> more\n\nplain");
}
#[gpui::test]
fn enter_in_a_quoted_list_makes_another_quoted_item(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("> - one", cx);
cx.simulate_keystrokes("end enter");
cx.simulate_input("two");
assert_eq!(source(&editor, &mut cx), "> - one\n> - two");
}
#[gpui::test]
fn enter_inside_a_quote_keeps_both_halves_quoted(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("> hello world", cx);
cx.simulate_keystrokes("home right right right right right enter");
assert_eq!(source(&editor, &mut cx), "> hello\n>\n> world");
}
#[gpui::test]
fn enter_inside_an_alert_keeps_the_alert(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("> [!NOTE]\n> hello world", cx);
cx.simulate_keystrokes("home right right right right right enter");
assert_eq!(source(&editor, &mut cx), "> [!NOTE]\n> hello\n>\n> world");
}
#[gpui::test]
fn the_slash_menu_owns_every_enter_chord(cx: &mut TestAppContext) {
for chord in ["enter", "shift-enter", "ctrl-enter"] {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("/note");
cx.simulate_keystrokes(chord);
assert_eq!(
source(&editor, &mut cx),
"> [!NOTE]",
"{chord} picked from the menu instead of editing under it"
);
}
}
#[gpui::test]
fn a_marker_typed_above_a_body_promotes_and_keeps_it(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("> ");
cx.simulate_input("body");
cx.simulate_keystrokes("home");
cx.simulate_input("[!NOTE]");
cx.simulate_keystrokes("shift-enter");
assert_eq!(source(&editor, &mut cx), "> [!NOTE]\n> body");
cx.simulate_input("X");
assert_eq!(source(&editor, &mut cx), "> [!NOTE]\n> Xbody");
}
#[gpui::test]
fn ctrl_enter_inside_a_list_breaks_it_in_two(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("- one\n- two", cx);
cx.simulate_keystrokes("end ctrl-enter");
cx.simulate_input("mid");
assert_eq!(source(&editor, &mut cx), "- one\n\nmid\n\n- two");
}
#[gpui::test]
fn a_block_prefix_typed_in_a_heading_stays_text(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("## Foo", cx);
cx.simulate_keystrokes("home");
cx.simulate_input("1. ");
assert_eq!(source(&editor, &mut cx), "## 1. Foo");
}
#[gpui::test]
fn text_goes_either_side_of_a_chip_alone_on_its_line(cx: &mut TestAppContext) {
let chip = "[https://x.com](https://x.com \"chip\")";
let (editor, _window, mut cx) = open_with(chip, cx);
cx.simulate_keystrokes("end");
cx.simulate_input(" after");
cx.simulate_keystrokes("home");
cx.simulate_input("before ");
assert_eq!(source(&editor, &mut cx), format!("before {chip} after"));
}
#[gpui::test]
fn backspace_takes_a_mention_whole(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("a [https://x.com](https://x.com \"chip\")", cx);
cx.simulate_keystrokes("end backspace");
assert_eq!(source(&editor, &mut cx), "a");
}
#[gpui::test]
fn typing_an_alert_marker_after_quote_shortcut_promotes_it(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("> ");
cx.simulate_input("[!NOTE]");
cx.simulate_keystrokes("shift-enter");
cx.simulate_input("body");
assert_eq!(source(&editor, &mut cx), "> [!NOTE]\n> body");
}
#[gpui::test]
fn the_slash_menu_offers_every_gfm_alert_quote(cx: &mut TestAppContext) {
for (query, expected) in [
("note", "> [!NOTE]"),
("tip", "> [!TIP]"),
("important", "> [!IMPORTANT]"),
("warning", "> [!WARNING]"),
("caution", "> [!CAUTION]"),
] {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("/");
cx.simulate_input(query);
cx.simulate_keystrokes("enter");
assert_eq!(
source(&editor, &mut cx),
expected,
"{query} picked the right alert"
);
}
}
const TABLE: &str = "| a | b |\n| --- | --- |\n| c | d |";
#[gpui::test]
fn tab_walks_the_cells_and_adds_a_row_past_the_last(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with(TABLE, cx);
cx.simulate_keystrokes("tab");
cx.simulate_input("1");
cx.simulate_keystrokes("tab tab tab");
cx.simulate_input("x");
cx.simulate_keystrokes("shift-tab");
cx.simulate_input("2");
assert_eq!(
source(&editor, &mut cx),
"| a | b1 |\n| --- | --- |\n| c | d2 |\n| x | |"
);
}
#[gpui::test]
fn enter_in_a_cell_goes_down_a_row(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with(TABLE, cx);
cx.simulate_keystrokes("tab enter");
cx.simulate_input("1");
cx.simulate_keystrokes("enter");
cx.simulate_input("2");
assert_eq!(
source(&editor, &mut cx),
"| a | b |\n| --- | --- |\n| c | d1 |\n| | 2 |"
);
}
#[gpui::test]
fn a_column_goes_into_the_header_and_every_row(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with(TABLE, cx);
editor.update(&mut cx, |editor, cx| editor.add_column(0, 1, cx));
cx.simulate_input("x");
assert_eq!(
source(&editor, &mut cx),
"| a | x | b |\n| --- | --- | --- |\n| c | | d |"
);
}
#[gpui::test]
fn rows_and_columns_go_in_and_out_anywhere(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with(TABLE, cx);
editor.update(&mut cx, |editor, cx| editor.add_row(0, 1, cx));
cx.simulate_input("x");
assert_eq!(
source(&editor, &mut cx),
"| a | b |\n| --- | --- |\n| x | |\n| c | d |"
);
editor.update(&mut cx, |editor, cx| {
editor.remove_column(0, 0, cx);
editor.remove_row(0, 2, cx);
});
assert_eq!(source(&editor, &mut cx), "| b |\n| --- |\n| |");
editor.update(&mut cx, |editor, cx| {
editor.remove_column(0, 0, cx);
editor.remove_row(0, 0, cx);
});
assert_eq!(
source(&editor, &mut cx),
"| b |\n| --- |\n| |",
"the last column and the header row stay"
);
}
const TO_QUOTE: &str = "down down down down down down down";
#[gpui::test]
fn right_opens_the_quote_group_and_enter_picks_inside_it(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("/");
cx.simulate_keystrokes(&format!("{TO_QUOTE} right down enter"));
assert_eq!(source(&editor, &mut cx), "> [!NOTE]");
}
#[gpui::test]
fn enter_on_the_quote_group_opens_it(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("/");
cx.simulate_keystrokes(&format!("{TO_QUOTE} enter down down enter"));
assert_eq!(source(&editor, &mut cx), "> [!TIP]");
}
#[gpui::test]
fn left_closes_the_quote_group(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.simulate_input("/");
cx.simulate_keystrokes(&format!("{TO_QUOTE} right left down enter"));
assert!(
source(&editor, &mut cx).starts_with("```"),
"the row after the group is Code: {:?}",
source(&editor, &mut cx)
);
}
#[gpui::test]
fn home_stays_on_the_softbreak_line(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("ab\ncd", cx);
cx.simulate_keystrokes("right right right home");
cx.simulate_input("X");
assert_eq!(source(&editor, &mut cx), "ab\nXcd");
}
#[gpui::test]
fn end_stays_on_the_softbreak_line(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("ab\ncd", cx);
cx.simulate_keystrokes("end");
cx.simulate_input("X");
assert_eq!(source(&editor, &mut cx), "abX\ncd");
}
#[gpui::test]
fn down_moves_to_the_second_softbreak_line(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("ab\ncd", cx);
cx.simulate_keystrokes("down");
cx.simulate_input("X");
assert_eq!(source(&editor, &mut cx), "ab\nXcd");
}
#[gpui::test]
fn down_crosses_every_block_boundary(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
let mut seen = vec![head(&editor, &mut cx)];
for _ in 0..12 {
cx.simulate_keystrokes("down");
seen.push(head(&editor, &mut cx));
}
let blocks: Vec<usize> = seen.iter().map(|at| at.block).collect();
assert!(
blocks.windows(2).all(|pair| pair[1] >= pair[0]),
"the caret never goes backwards: {blocks:?}"
);
assert_eq!(
*blocks.last().unwrap(),
4,
"and it reaches the last block: {blocks:?}"
);
}
#[gpui::test]
fn down_does_not_stick_inside_a_wrapped_block(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.simulate_keystrokes("down");
let mut rows = Vec::new();
for _ in 0..4 {
cx.simulate_keystrokes("down");
rows.push(head(&editor, &mut cx));
}
assert!(
rows.windows(2).all(|pair| pair[0] != pair[1]),
"every step moves: {rows:?}"
);
}
#[gpui::test]
fn home_keeps_the_start_of_a_wrapped_row(cx: &mut TestAppContext) {
let text = "word ".repeat(80);
let (editor, _window, mut cx) = open_with(&text, cx);
cx.simulate_keystrokes("down right home");
let row_start = head(&editor, &mut cx);
assert!(row_start.offset > 0, "the test reached a wrapped row");
cx.simulate_keystrokes("left");
assert_eq!(head(&editor, &mut cx), row_start);
cx.simulate_keystrokes("left");
assert!(head(&editor, &mut cx).offset < row_start.offset);
}
#[gpui::test]
fn end_keeps_the_end_of_a_wrapped_row(cx: &mut TestAppContext) {
let text = "word ".repeat(80);
let (editor, _window, mut cx) = open_with(&text, cx);
cx.simulate_keystrokes("down right");
let (row, _) = caret(&editor, &mut cx);
cx.simulate_keystrokes("end");
let row_end = head(&editor, &mut cx);
assert!(
row_end.offset < text.len(),
"the test has another wrapped row"
);
let (end, _) = caret(&editor, &mut cx);
assert_eq!(end.y, row.y, "End paints on the row it was pressed on");
cx.simulate_keystrokes("right");
assert_eq!(head(&editor, &mut cx), row_end);
assert!(caret(&editor, &mut cx).0.y > row.y);
cx.simulate_keystrokes("right");
assert!(head(&editor, &mut cx).offset > row_end.offset);
}
#[gpui::test]
fn home_in_wrapped_multibyte_inline_code_keeps_its_row(cx: &mut TestAppContext) {
let text = "日本語🙂 `some code` ".repeat(30);
let (editor, _window, mut cx) = open_with(&text, cx);
cx.simulate_keystrokes("down right home");
let row_start = head(&editor, &mut cx);
let body = cx.update(|_, cx| {
editor.read(cx).doc().blocks[0]
.text_at(markdown::Part::Body)
.unwrap()
.text
.clone()
});
assert!(body.is_char_boundary(row_start.offset));
assert!(row_start.offset > 0, "the test reached a wrapped row");
cx.simulate_keystrokes("left");
assert_eq!(head(&editor, &mut cx), row_start);
}
#[gpui::test]
fn vertical_motion_uses_the_new_caret_after_enter(cx: &mut TestAppContext) {
let text = "word ".repeat(80);
let (editor, _window, mut cx) = open_with(&text, cx);
cx.simulate_keystrokes("down right right enter");
cx.run_until_parked();
let created = head(&editor, &mut cx);
assert_eq!(created.block, 1);
assert_eq!(created.offset, 0);
cx.simulate_keystrokes("up down");
assert_eq!(head(&editor, &mut cx), created);
}
#[gpui::test]
fn vertical_motion_uses_the_new_caret_after_shift_enter(cx: &mut TestAppContext) {
let text = "word ".repeat(80);
let (editor, _window, mut cx) = open_with(&text, cx);
cx.simulate_keystrokes("down right right shift-enter");
cx.run_until_parked();
let created = head(&editor, &mut cx);
assert!(created.offset > 0);
cx.simulate_keystrokes("up down");
assert_eq!(head(&editor, &mut cx), created);
}
#[gpui::test]
fn up_retraces_the_path_down(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
let start = head(&editor, &mut cx);
cx.simulate_keystrokes("down down down");
assert_ne!(head(&editor, &mut cx), start);
cx.simulate_keystrokes("up up up");
assert_eq!(
head(&editor, &mut cx),
start,
"the goal column is held across the whole run"
);
}
#[gpui::test]
fn a_selection_held_past_the_bottom_scrolls_and_extends(cx: &mut TestAppContext) {
let source: String = (0..40).map(|ix| format!("line {ix}\n\n")).collect();
let (editor, mut cx) = open_scrolling_with(&source, cx);
let start = cx.update(|_, cx| {
let editor = editor.read(cx);
let (at, line) = editor
.layouts()
.position(
markdown::Cursor::new(0, markdown::Part::Body, 0),
markdown::Affinity::Downstream,
)
.unwrap();
at + point(px(1.0), line / 2.0)
});
cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default());
let past = point(start.x, px(120.0));
cx.simulate_mouse_move(past, gpui::MouseButton::Left, gpui::Modifiers::default());
let reached = head(&editor, &mut cx).block;
cx.executor()
.advance_clock(std::time::Duration::from_millis(500));
cx.run_until_parked();
cx.simulate_mouse_up(past, gpui::MouseButton::Left, gpui::Modifiers::default());
assert!(
head(&editor, &mut cx).block > reached,
"the selection grew past block {reached}"
);
}
#[gpui::test]
fn vertical_motion_keeps_its_row_while_scrolling(cx: &mut TestAppContext) {
let source = "word ".repeat(100);
let (editor, mut cx) = open_scrolling_with(&source, cx);
cx.simulate_keystrokes("down down");
let before = head(&editor, &mut cx);
let before_y = cx.update(|_, cx| {
let editor = editor.read(cx);
editor
.layouts()
.position(editor.selection().head, editor.selection().affinity)
.unwrap()
.0
.y
});
cx.simulate_event(gpui::ScrollWheelEvent {
position: point(px(100.0), px(35.0)),
delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-35.0))),
modifiers: Default::default(),
touch_phase: gpui::TouchPhase::Moved,
});
cx.run_until_parked();
let after_y = cx.update(|_, cx| {
let editor = editor.read(cx);
editor
.layouts()
.position(editor.selection().head, editor.selection().affinity)
.unwrap()
.0
.y
});
assert_ne!(before_y, after_y, "the test must scroll the painted rows");
cx.simulate_keystrokes("down");
assert!(head(&editor, &mut cx) > before);
}
#[gpui::test]
fn typing_after_vertical_motion_sets_a_new_goal_column(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("abcdefghij\nx\nabcdefghij", cx);
cx.simulate_keystrokes("right right right right right right right down");
assert_eq!(head(&editor, &mut cx).offset, 12);
cx.simulate_input("y");
assert_eq!(source(&editor, &mut cx), "abcdefghij\nxy\nabcdefghij");
assert_eq!(head(&editor, &mut cx).offset, 13);
cx.simulate_keystrokes("down");
assert_eq!(head(&editor, &mut cx).offset, 16);
}
#[gpui::test]
fn vertical_motion_chooses_the_nearest_column(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("xy\nabcdefghij", cx);
cx.simulate_keystrokes("right right down");
assert_eq!(head(&editor, &mut cx).offset, 5);
}
#[cfg(target_os = "macos")]
const DOCUMENT_ENDS: [&str; 3] = ["cmd-up", "cmd-down", "cmd-shift-down"];
#[cfg(not(target_os = "macos"))]
const DOCUMENT_ENDS: [&str; 3] = ["ctrl-home", "ctrl-end", "ctrl-shift-end"];
#[gpui::test]
fn chords_move_and_select_to_document_ends(cx: &mut TestAppContext) {
let [start, end, select_end] = DOCUMENT_ENDS;
let (editor, _window, mut cx) = open_with("first\n\nsecond\n\nthird", cx);
cx.simulate_keystrokes(end);
assert_eq!(head(&editor, &mut cx).block, 2);
assert_eq!(head(&editor, &mut cx).offset, 5);
cx.simulate_keystrokes(start);
assert_eq!(head(&editor, &mut cx).block, 0);
assert_eq!(head(&editor, &mut cx).offset, 0);
cx.simulate_keystrokes(select_end);
let selection = cx.update(|_, cx| editor.read(cx).selection());
assert_eq!(selection.anchor.block, 0);
assert_eq!(selection.head.block, 2);
assert_eq!(selection.head.offset, 5);
}
#[gpui::test]
fn the_goal_column_survives_both_ends(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("abcdefghij\nabcdefghij\nabcdefghij", cx);
cx.simulate_keystrokes("right right right right right down down down up");
assert_eq!(head(&editor, &mut cx).offset, 16);
cx.simulate_keystrokes("down up up up down");
assert_eq!(head(&editor, &mut cx).offset, 16);
}
#[gpui::test]
fn the_menu_opens_on_a_slash_and_turns_the_block(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.simulate_keystrokes("end enter");
cx.simulate_input("/");
cx.simulate_keystrokes("down enter");
assert!(
source(&editor, &mut cx).starts_with("# Title\n\n# "),
"Enter took Heading 1 from the menu: {:?}",
source(&editor, &mut cx)
);
}
#[gpui::test]
fn the_menu_actually_paints(cx: &mut TestAppContext) {
let (_editor, _window, mut cx) = open(cx);
assert!(
cx.debug_bounds(editor::SLASH_MENU).is_none(),
"nothing is open yet"
);
cx.simulate_keystrokes("end enter");
cx.simulate_input("/");
cx.run_until_parked();
assert!(
cx.debug_bounds(editor::SLASH_MENU).is_some(),
"the menu reached the screen, not just the state"
);
}
#[gpui::test]
fn escape_closes_the_menu_and_gives_enter_back(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.simulate_keystrokes("end enter");
cx.simulate_input("/");
cx.simulate_keystrokes("escape enter");
assert!(
source(&editor, &mut cx).starts_with("# Title\n\n/"),
"with the menu shut, Enter splits and the slash stays literal: {:?}",
source(&editor, &mut cx)
);
}
#[cfg(target_os = "macos")]
const PRIMARY: &str = "cmd";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "ctrl";
#[gpui::test]
fn a_selection_survives_a_mark_and_round_trips(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.simulate_keystrokes("shift-right shift-right shift-right");
assert!(
!cx.update(|_, cx| editor.read(cx).selection().is_collapsed()),
"shift+arrow extends"
);
cx.simulate_keystrokes(&format!("{PRIMARY}-b"));
assert!(
source(&editor, &mut cx).starts_with("# **Tit**le"),
"cmd-B marks the selection: {:?}",
source(&editor, &mut cx)
);
}
#[gpui::test]
fn undo_gives_back_a_run_of_typing_at_once(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
let before = source(&editor, &mut cx);
cx.simulate_input("hello");
assert_ne!(source(&editor, &mut cx), before);
cx.simulate_keystrokes(&format!("{PRIMARY}-z"));
assert_eq!(
source(&editor, &mut cx),
before,
"one step takes the whole word"
);
}
#[gpui::test]
fn tab_indents_a_list_item_and_shift_tab_puts_it_back(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
go_to_block(&editor, &mut cx, 3);
cx.simulate_keystrokes("tab");
assert!(
source(&editor, &mut cx).contains("- first\n - second"),
"tab nests it under the item above: {:?}",
source(&editor, &mut cx)
);
cx.simulate_keystrokes("shift-tab");
assert!(
source(&editor, &mut cx).contains("- first\n- second"),
"and shift-tab lifts it back"
);
}
#[gpui::test]
fn tab_indents_with_focus_traversal_installed(cx: &mut TestAppContext) {
use gpui::{Context, IntoElement, Render, Window, div, prelude::*};
struct Host(Entity<Editor>);
impl Render for Host {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
ui::focus::traversal(div())
.size_full()
.child(self.0.clone())
}
}
cx.update(|cx| {
theme::Theme::install(theme::Appearance::Dark, cx);
editor::init(cx);
ui::focus::init(cx);
});
let window = cx.add_window(|_, cx| Host(cx.new(|cx| Editor::new(SOURCE, cx))));
let editor = cx.update(|cx| window.root(cx).unwrap().read(cx).0.clone());
let mut cx = VisualTestContext::from_window(window.into(), cx);
cx.simulate_resize(size(px(360.0), px(600.0)));
cx.update(|window, cx| {
let handle = editor.read(cx).focus_handle(cx);
window.focus(&handle, cx);
});
cx.run_until_parked();
go_to_block(&editor, &mut cx, 3);
cx.simulate_keystrokes("tab");
assert!(
source(&editor, &mut cx).contains("- first\n - second"),
"tab nests the item rather than moving focus: {:?}",
source(&editor, &mut cx)
);
cx.simulate_keystrokes("shift-tab");
assert!(
source(&editor, &mut cx).contains("- first\n- second"),
"and shift-tab lifts it back rather than stepping the other way"
);
assert!(
cx.update(|window, cx| editor.read(cx).focus_handle(cx).is_focused(window)),
"the document still holds focus"
);
}
#[gpui::test]
fn the_handle_follows_the_caret(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
let at = cx
.debug_bounds(editor::BLOCK_HANDLE)
.expect("a focused document shows a handle without being hovered");
go_to_block(&editor, &mut cx, 2);
cx.run_until_parked();
let moved = cx
.debug_bounds(editor::BLOCK_HANDLE)
.expect("and still shows one");
assert!(
moved.origin.y > at.origin.y,
"it followed the caret down the document"
);
}
#[gpui::test]
fn the_handle_moves_in_with_an_indented_block(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("- first\n- second", &mut *cx);
go_to_block(&editor, &mut cx, 1);
cx.run_until_parked();
let before = cx
.debug_bounds(editor::BLOCK_HANDLE)
.expect("a handle")
.origin;
cx.simulate_keystrokes("tab");
cx.update(|window, cx| window.simulate_next_frame(cx));
cx.run_until_parked();
let after = cx
.debug_bounds(editor::BLOCK_HANDLE)
.expect("still a handle")
.origin;
assert_eq!(
after.x - before.x,
px(22.0),
"the handle followed the block in by one indent"
);
assert_eq!(after.y, before.y, "and stayed on the same row");
}
#[gpui::test]
fn an_empty_block_after_an_atomic_one_deletes(cx: &mut TestAppContext) {
for (name, source) in [
("a rule", "a\n\n---\n\nx"),
("a fence", "a\n\n```rs\nk\n```\n\nx"),
("an image", "a\n\n\n\nx"),
("a table", "a\n\n| h |\n| - |\n| c |\n\nx"),
] {
let (editor, _window, mut cx) = open_with(source, &mut *cx);
for _ in 0..25 {
cx.simulate_keystrokes("down");
}
cx.simulate_keystrokes("end backspace");
let before = cx.update(|_, cx| editor.read(cx).doc().blocks.len());
cx.simulate_keystrokes("backspace");
assert_eq!(
cx.update(|_, cx| editor.read(cx).doc().blocks.len()),
before - 1,
"an empty paragraph after {name} is deletable"
);
}
}
#[gpui::test]
fn a_second_press_on_the_handle_leaves_the_block_menu_shut(cx: &mut TestAppContext) {
let (_editor, _window, mut cx) = open(cx);
cx.run_until_parked();
let handle = cx
.debug_bounds(editor::BLOCK_HANDLE)
.expect("the focused caret's block paints a handle");
let press = handle.origin + gpui::point(px(15.0), px(15.0));
let press_again = handle.origin + gpui::point(px(3.0), px(3.0));
cx.simulate_click(press, gpui::Modifiers::default());
cx.run_until_parked();
assert!(
cx.debug_bounds(editor::BLOCK_MENU).is_some(),
"the first press opens it"
);
cx.simulate_click(press_again, gpui::Modifiers::default());
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert!(
cx.debug_bounds(editor::BLOCK_MENU).is_none(),
"the second press leaves it shut instead of closing and reopening"
);
}
#[gpui::test]
fn the_block_menu_keeps_quotes_behind_a_submenu(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("hello", cx);
cx.run_until_parked();
let handle = cx.debug_bounds(editor::BLOCK_HANDLE).unwrap();
cx.simulate_click(handle.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert!(cx.debug_bounds("block-menu/Quote").is_some());
assert!(cx.debug_bounds("block-menu/Quote (Note)").is_none());
cx.simulate_keystrokes("down down down down down down down down right down enter");
cx.run_until_parked();
assert_eq!(source(&editor, &mut cx), "> [!NOTE]\n> hello");
}
#[gpui::test]
fn a_copied_image_file_pastes_as_the_picture(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
let path = std::path::PathBuf::from("/My Notes/shot.png");
cx.update(|_, cx| {
cx.write_to_clipboard(ClipboardItem {
entries: vec![
ClipboardEntry::ExternalPaths(ExternalPaths(vec![path.clone()].into())),
ClipboardEntry::String(ClipboardString::new(path.display().to_string())),
],
})
});
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "");
}
#[gpui::test]
fn a_copied_file_that_is_not_a_picture_pastes_its_path(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
let path = std::path::PathBuf::from("/tmp/notes.txt");
cx.update(|_, cx| {
cx.write_to_clipboard(ClipboardItem {
entries: vec![
ClipboardEntry::ExternalPaths(ExternalPaths(vec![path.clone()].into())),
ClipboardEntry::String(ClipboardString::new(path.display().to_string())),
],
})
});
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "/tmp/notes.txt");
}
static ASKED: Mutex<Option<EntityId>> = Mutex::new(None);
fn keep(
source: Source,
editor: &Entity<Editor>,
base: Option<&std::path::Path>,
_: &App,
) -> Option<String> {
*ASKED.lock().unwrap() = Some(editor.entity_id());
if let Some(base) = base {
return Some(format!("{}/{}", base.display(), path_name(&source)?));
}
match source {
Source::File(path) => Some(format!("media://{}", path.file_name()?.to_str()?)),
Source::Bytes(_) => None,
}
}
fn path_name(source: &Source) -> Option<String> {
match source {
Source::File(path) => Some(path.file_name()?.to_str()?.to_string()),
Source::Bytes(_) => None,
}
}
fn copy_file(path: &str, cx: &mut VisualTestContext) {
let path = std::path::PathBuf::from(path);
cx.update(|_, cx| {
cx.write_to_clipboard(ClipboardItem {
entries: vec![
ClipboardEntry::ExternalPaths(ExternalPaths(vec![path.clone()].into())),
ClipboardEntry::String(ClipboardString::new(path.display().to_string())),
],
})
});
}
#[gpui::test]
fn the_store_is_told_which_editor_is_asking(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.update(|_, cx| {
cx.set_image_store(ImageStore {
keep,
..ImageStore::default()
})
});
copy_file("/My Notes/shot.png", &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "");
assert_eq!(
*ASKED.lock().unwrap(),
Some(editor.entity_id()),
"the store was asked on behalf of the editor that pasted"
);
}
#[gpui::test]
fn a_store_decides_for_itself_what_a_picture_is(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.update(|_, cx| {
cx.set_image_store(ImageStore {
keep,
accepts: |path| path.extension().is_some_and(|ext| ext == "heic"),
})
});
copy_file("/My Notes/shot.heic", &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "");
}
#[gpui::test]
fn a_file_the_store_refuses_pastes_as_its_path(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("", cx);
cx.update(|_, cx| {
cx.set_image_store(ImageStore {
keep,
accepts: |path| path.extension().is_some_and(|ext| ext == "heic"),
})
});
copy_file("/My Notes/shot.png", &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "/My Notes/shot.png");
}
#[gpui::test]
fn the_source_keeps_the_caret_and_gives_it_back(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
go_to_block(&editor, &mut cx, 2);
cx.simulate_keystrokes("right right right");
let before = head(&editor, &mut cx);
let document = source(&editor, &mut cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
assert_eq!(
cx.update(|_, cx| editor.read(cx).mode()),
editor::Mode::Source
);
assert_eq!(
source(&editor, &mut cx),
document,
"the source view holds exactly what a save would write"
);
let at = head(&editor, &mut cx);
assert_eq!(at.part, markdown::Part::Code, "one text, the fence's");
assert!(at.offset > 0, "and the caret came with it");
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
assert_eq!(head(&editor, &mut cx), before, "and goes back where it was");
assert_eq!(source(&editor, &mut cx), document, "with nothing moved");
}
#[gpui::test]
fn typing_in_the_source_is_typing_in_the_document(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("# Title\n\nbody", cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
cx.simulate_keystrokes("home");
cx.simulate_input("#");
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
assert_eq!(
cx.update(|_, cx| editor.read(cx).doc().blocks[0].kind.clone()),
markdown::BlockKind::Heading {
level: 2,
text: markdown::Text::plain("Title"),
},
"a `#` typed into the markup is a heading level when the document comes back"
);
}
#[cfg(target_os = "macos")]
#[gpui::test]
fn ctrl_k_in_the_source_kills_to_the_end_of_the_line(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("# Title\n\nbody", cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
cx.simulate_keystrokes("home ctrl-k");
assert_eq!(
source(&editor, &mut cx),
"\n\nbody",
"the line, not the rest"
);
cx.simulate_keystrokes("ctrl-k");
assert_eq!(
source(&editor, &mut cx),
"\nbody",
"at its end, the newline"
);
}
#[gpui::test]
fn a_triple_click_in_the_source_selects_one_line(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("# Title\n\nbody", cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
let at = cx.update(|_, cx| {
let editor = editor.read(cx);
let (at, line_height) = editor
.layouts()
.position(editor.selection().head, editor.selection().affinity)
.unwrap();
point(at.x, at.y + line_height / 2.)
});
cx.simulate_event(gpui::MouseDownEvent {
position: at,
button: gpui::MouseButton::Left,
modifiers: gpui::Modifiers::default(),
click_count: 3,
first_mouse: false,
});
cx.simulate_mouse_up(at, gpui::MouseButton::Left, gpui::Modifiers::default());
let (start, end) = cx.update(|_, cx| editor.read(cx).selection().ordered());
assert_eq!(
(start.offset, end.offset),
(0, "# Title".len()),
"the line, not the source"
);
}
#[gpui::test]
fn enter_in_the_source_is_a_newline_and_undo_crosses_the_switch(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("# Title", cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
cx.simulate_keystrokes("end enter");
cx.simulate_input("body");
assert_eq!(
source(&editor, &mut cx),
"# Title\nbody",
"enter is a newline in the markup rather than a split"
);
cx.simulate_keystrokes(&format!("{PRIMARY}-z {PRIMARY}-z {PRIMARY}-z"));
cx.run_until_parked();
assert_eq!(
cx.update(|_, cx| editor.read(cx).mode()),
editor::Mode::Blocks,
"stepping back over the switch comes back to the document"
);
assert_eq!(source(&editor, &mut cx), "# Title");
}
#[gpui::test]
fn the_block_chrome_stays_out_of_the_source(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
assert!(
!cx.debug_bounds(editor::BLOCK_HANDLE).is_some(),
"no handle: there are no blocks to drag"
);
cx.update(|_, cx| {
editor.update(cx, |editor, cx| {
editor.set_block(
0,
markdown::BlockKind::Heading {
level: 1,
text: markdown::Text::default(),
},
cx,
);
});
});
assert_eq!(
cx.update(|_, cx| editor.read(cx).mode()),
editor::Mode::Source
);
assert!(
source(&editor, &mut cx).starts_with("# Title"),
"and the source is untouched"
);
}
#[gpui::test]
fn the_source_survives_being_emptied(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("# Title\n\nbody", cx);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
cx.simulate_keystrokes(&format!("{PRIMARY}-a backspace backspace backspace"));
cx.run_until_parked();
assert_eq!(source(&editor, &mut cx), "", "the source is empty");
assert_eq!(
head(&editor, &mut cx).part,
markdown::Part::Code,
"and the caret is still in the text the source view paints"
);
cx.simulate_input("hi");
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
assert_eq!(
source(&editor, &mut cx),
"hi",
"and typing carries back out"
);
}
#[gpui::test]
fn formatting_answers_for_the_whole_bar(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("# Title\n\n**bold** tail", cx);
let formatting = |cx: &mut VisualTestContext| cx.update(|_, cx| editor.read(cx).formatting());
let at_title = formatting(&mut cx);
assert_eq!(at_title.block.as_deref(), Some("Heading 1"));
assert!(at_title.marks.is_empty(), "nothing marked at the start");
assert!(!at_title.fenceable, "and one line is not a fence");
go_to_block(&editor, &mut cx, 1);
cx.simulate_keystrokes("right right right right");
let in_bold = formatting(&mut cx);
assert_eq!(in_bold.block.as_deref(), Some("Text"));
assert_eq!(in_bold.marks, vec![markdown::Mark::Bold]);
cx.simulate_keystrokes(&format!("end {PRIMARY}-b"));
assert_eq!(formatting(&mut cx).marks, vec![markdown::Mark::Bold]);
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
let in_source = formatting(&mut cx);
assert_eq!(in_source.mode, editor::Mode::Source);
assert!(in_source.marks.is_empty() && !in_source.fenceable);
}
#[gpui::test]
fn a_selection_over_two_blocks_reads_as_fenceable(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("one\n\ntwo", cx);
cx.simulate_keystrokes("shift-down shift-end");
let formatting = cx.update(|_, cx| editor.read(cx).formatting());
assert!(
formatting.fenceable,
"cmd-E over two blocks makes a fence, which only the editor can say"
);
}
fn open_built(
source: &str,
build: impl FnOnce(Editor) -> Editor,
cx: &mut TestAppContext,
) -> (Entity<Editor>, VisualTestContext) {
open_made(|cx| build(Editor::new(source, cx)), cx)
}
fn open_document(
source: &str,
build: impl FnOnce(editor::Document) -> editor::Document,
cx: &mut TestAppContext,
) -> (Entity<Editor>, VisualTestContext) {
open_made(
|cx| {
let document = cx.new(|cx| build(editor::Document::new(source, cx)));
Editor::for_document(document, cx)
},
cx,
)
}
fn open_made(
make: impl FnOnce(&mut gpui::Context<Editor>) -> Editor,
cx: &mut TestAppContext,
) -> (Entity<Editor>, VisualTestContext) {
cx.update(|cx| {
theme::Theme::install(theme::Appearance::Dark, cx);
editor::init(cx);
});
let window = cx.add_window(|_, cx| make(cx));
let editor = window.root(cx).unwrap();
let mut visual = VisualTestContext::from_window(window.into(), cx);
visual.simulate_resize(size(px(360.0), px(600.0)));
visual.update(|window, cx| {
let handle = editor.read(cx).focus_handle(cx);
window.focus(&handle, cx);
});
visual.run_until_parked();
(editor, visual)
}
#[gpui::test]
fn chrome_turned_off_never_reaches_the_screen(cx: &mut TestAppContext) {
let plain = editor::Chrome {
handle: false,
slash: false,
..Default::default()
};
let (editor, mut cx) = open_built("# Title", move |editor| editor.with_chrome(plain), cx);
assert!(
cx.debug_bounds(editor::BLOCK_HANDLE).is_none(),
"no gutter handle on a document that asked for none"
);
cx.simulate_keystrokes("end enter");
cx.simulate_input("/");
cx.run_until_parked();
assert!(
cx.debug_bounds(editor::SLASH_MENU).is_none(),
"and the slash is a slash"
);
assert_eq!(
source(&editor, &mut cx),
"# Title\n\n/",
"which is typed into the document like any other character"
);
}
#[gpui::test]
fn an_editor_can_open_on_its_source(cx: &mut TestAppContext) {
let (editor, mut cx) = open_document(
"# Title\n\nbody",
|document| document.with_mode(editor::Mode::Source),
cx,
);
assert_eq!(
cx.update(|_, cx| editor.read(cx).mode()),
editor::Mode::Source
);
assert_eq!(source(&editor, &mut cx), "# Title\n\nbody");
assert_eq!(
head(&editor, &mut cx).part,
markdown::Part::Code,
"the caret is in the text the source view paints"
);
}
#[gpui::test]
fn an_app_mark_survives_the_editor(cx: &mut TestAppContext) {
let marks = markdown::Marks::new().with("highlight", "==");
let (editor, mut cx) = open_document(
"a ==lit== word",
move |document| document.with_marks(marks),
cx,
);
let highlight = markdown::Mark::Custom("highlight".into());
cx.simulate_keystrokes("right right right");
assert_eq!(
cx.update(|_, cx| editor.read(cx).formatting().marks),
vec![highlight.clone()],
"a mark the library has never heard of lights a button like any other"
);
assert_eq!(
source(&editor, &mut cx),
"a ==lit== word",
"and is written back with the delimiter that spells it"
);
cx.update(|_, cx| {
editor.update(cx, |editor, cx| {
editor.select(
markdown::Selection::new(
markdown::Cursor::new(0, markdown::Part::Body, 2),
markdown::Cursor::new(0, markdown::Part::Body, 5),
),
cx,
);
editor.toggle_mark(highlight, cx);
})
});
assert_eq!(
source(&editor, &mut cx),
"a lit word",
"and the same toggle takes it off again"
);
}
#[gpui::test]
fn platform_ranges_use_utf16_for_chinese_and_emoji(cx: &mut TestAppContext) {
use gpui::EntityInputHandler;
let (_, window, _) = open_with("中😀abc!", cx);
window
.update(cx, |editor, window, cx| {
editor.select(
markdown::Selection::at(markdown::Cursor {
offset: 7,
..Default::default()
}),
cx,
);
assert_eq!(
editor.selected_text_range(false, window, cx).unwrap().range,
3..3
);
let mut adjusted = None;
assert_eq!(
editor
.text_for_range(1..3, &mut adjusted, window, cx)
.as_deref(),
Some("😀")
);
assert_eq!(adjusted, Some(1..3));
editor.replace_text_in_range(Some(1..3), "文", window, cx);
assert_eq!(editor.source().trim(), "中文abc!");
editor.replace_and_mark_text_in_range(Some(2..5), "😀文", Some(0..2), window, cx);
assert_eq!(editor.source().trim(), "中文😀文!");
assert_eq!(editor.marked_text_range(window, cx), Some(2..5));
assert_eq!(
editor.selected_text_range(false, window, cx).unwrap().range,
2..4
);
editor.replace_and_mark_text_in_range(None, "中文", Some(1..1), window, cx);
assert_eq!(editor.source().trim(), "中文中文!");
assert_eq!(
editor.selected_text_range(false, window, cx).unwrap().range,
3..3
);
editor.replace_text_in_range(None, "文", window, cx);
assert_eq!(editor.source().trim(), "中文文!");
assert_eq!(editor.marked_text_range(window, cx), None);
})
.unwrap();
}
#[gpui::test]
fn a_press_on_a_checkbox_toggles_it_and_leaves_the_caret(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("- [ ] open\n- [x] done", cx);
let caret = head(&editor, &mut cx);
let box_ = cx
.update(|_, cx| editor.read(cx).layouts().checkbox_bounds(0))
.expect("the first checkbox painted");
cx.simulate_click(box_.center(), gpui::Modifiers::default());
assert_eq!(
source(&editor, &mut cx),
"- [x] open\n- [x] done",
"the unchecked box checked"
);
assert_eq!(head(&editor, &mut cx), caret, "and the caret stayed put");
let box_ = cx
.update(|_, cx| editor.read(cx).layouts().checkbox_bounds(1))
.expect("the second checkbox painted");
cx.simulate_click(box_.center(), gpui::Modifiers::default());
assert_eq!(
source(&editor, &mut cx),
"- [x] open\n- [ ] done",
"and the checked one unchecked"
);
cx.simulate_keystrokes(&format!("{PRIMARY}-z"));
assert_eq!(
source(&editor, &mut cx),
"- [x] open\n- [x] done",
"one toggle is one undo step"
);
}
#[gpui::test]
fn a_press_beside_a_checkbox_places_a_caret(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("- [ ] open\n- [x] done", cx);
let box_ = cx
.update(|_, cx| editor.read(cx).layouts().checkbox_bounds(1))
.expect("the second checkbox painted");
cx.simulate_click(
gpui::point(box_.origin.x + box_.size.width + px(3.0), box_.center().y),
gpui::Modifiers::default(),
);
assert_eq!(
source(&editor, &mut cx),
"- [ ] open\n- [x] done",
"nothing toggled"
);
assert_eq!(head(&editor, &mut cx).block, 1, "the caret moved there");
}
#[gpui::test]
fn enter_at_the_start_of_an_alert_opens_a_line_above_inside_it(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("> [!NOTE]\n> body", cx);
cx.simulate_keystrokes("home enter up");
cx.simulate_input("above");
assert_eq!(source(&editor, &mut cx), "> [!NOTE]\n> above\n>\n> body");
}
#[gpui::test]
fn enter_in_an_empty_alert_leaves_it(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("> [!TIP]", cx);
cx.simulate_keystrokes("enter");
assert_eq!(source(&editor, &mut cx), "");
}
#[gpui::test]
fn the_quote_prefix_quotes_a_list_item(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("- item", cx);
cx.simulate_keystrokes("home");
cx.simulate_input("> ");
assert_eq!(source(&editor, &mut cx), "> - item");
}
fn copy_text(text: &str, cx: &mut VisualTestContext) {
cx.update(|_, cx| cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())));
}
#[gpui::test]
fn a_paste_in_source_goes_into_the_text_at_the_caret(cx: &mut TestAppContext) {
let (editor, mut cx) = open_document(
"# Title\n\nbody",
|document| document.with_mode(editor::Mode::Source),
cx,
);
let at = head(&editor, &mut cx).offset;
let pasted = "- a\n- b\n\n";
copy_text(pasted, &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
let mut expected = String::from("# Title\n\nbody");
expected.insert_str(at, pasted);
assert_eq!(source(&editor, &mut cx), expected, "spliced into the text");
let caret = head(&editor, &mut cx);
assert_eq!(
(caret.block, caret.part, caret.offset),
(0, markdown::Part::Code, at + pasted.len()),
"and the caret is after what was pasted"
);
}
#[gpui::test]
fn a_url_pasted_over_a_selection_in_source_replaces_it(cx: &mut TestAppContext) {
let (editor, mut cx) = open_document(
"body",
|document| document.with_mode(editor::Mode::Source),
cx,
);
cx.simulate_keystrokes(&format!("{PRIMARY}-a"));
copy_text("https://example.com", &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "https://example.com");
}
#[gpui::test]
fn a_copied_image_file_pastes_its_path_in_source(cx: &mut TestAppContext) {
let (editor, mut cx) =
open_document("", |document| document.with_mode(editor::Mode::Source), cx);
copy_file("/tmp/shot.png", &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "/tmp/shot.png");
}
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
}
#[gpui::test]
fn a_copy_in_source_is_the_text_without_a_fence(cx: &mut TestAppContext) {
let (editor, mut cx) = open_document(
"# Title\n\nbody",
|document| document.with_mode(editor::Mode::Source),
cx,
);
cx.simulate_keystrokes(&format!("{PRIMARY}-a {PRIMARY}-c"));
assert_eq!(clipboard(&mut cx).as_deref(), Some("# Title\n\nbody"));
cx.simulate_keystrokes(&format!("{PRIMARY}-x"));
assert_eq!(clipboard(&mut cx).as_deref(), Some("# Title\n\nbody"));
assert_eq!(source(&editor, &mut cx), "");
}
#[gpui::test]
fn a_copy_inside_a_code_block_is_the_code(cx: &mut TestAppContext) {
let (editor, mut cx) = open_built("```rust\nlet a = 1;\n```", |editor| editor, cx);
cx.update(|_, cx| {
editor.update(cx, |editor, cx| {
editor.select(
markdown::Selection::new(
markdown::Cursor::new(0, markdown::Part::Code, 4),
markdown::Cursor::new(0, markdown::Part::Code, 9),
),
cx,
)
})
});
cx.simulate_keystrokes(&format!("{PRIMARY}-c"));
assert_eq!(clipboard(&mut cx).as_deref(), Some("a = 1"));
}
#[gpui::test]
fn the_store_is_handed_the_editors_base(cx: &mut TestAppContext) {
let (editor, mut cx) = open_built("", |editor| editor.with_base("/notes/article"), cx);
cx.update(|_, cx| {
cx.set_image_store(ImageStore {
keep,
..ImageStore::default()
})
});
copy_file("/My Notes/shot.png", &mut cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-v"));
assert_eq!(source(&editor, &mut cx), "");
}
#[gpui::test]
fn the_highlight_chord_toggles_a_registered_highlight(cx: &mut TestAppContext) {
let marks = markdown::Marks::new().with(editor::HIGHLIGHT_MARK, "==");
let (editor, mut cx) =
open_document("a lit word", move |document| document.with_marks(marks), cx);
cx.update(|_, cx| {
editor.update(cx, |editor, cx| {
editor.select(
markdown::Selection::new(
markdown::Cursor::new(0, markdown::Part::Body, 2),
markdown::Cursor::new(0, markdown::Part::Body, 5),
),
cx,
)
})
});
cx.simulate_keystrokes(&format!("{PRIMARY}-shift-h"));
assert_eq!(source(&editor, &mut cx), "a ==lit== word");
cx.simulate_keystrokes(&format!("{PRIMARY}-shift-h"));
assert_eq!(source(&editor, &mut cx), "a lit word", "and back off");
}
#[gpui::test]
fn the_highlight_chord_does_nothing_without_the_mark(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open_with("a lit word", cx);
cx.simulate_keystrokes(&format!("{PRIMARY}-a {PRIMARY}-shift-h"));
assert_eq!(source(&editor, &mut cx), "a lit word");
}
#[gpui::test]
fn a_table_row_handle_survives_the_pointer_crossing_cell_padding(cx: &mut TestAppContext) {
let (editor, _, mut cx) = open_with(
"paragraph\n\n| A | B |\n| --- | --- |\n| first | row |\n| second | row |",
cx,
);
let (table, cell) = cx.update(|_, cx| {
let layouts = editor.read(cx).layouts();
(
layouts.block_bounds(1).unwrap(),
layouts
.cell_bounds(1, markdown::Part::Cell { row: 1, column: 0 })
.unwrap(),
)
});
let mut x = cell.center().x;
while x >= table.left() {
cx.simulate_mouse_move(point(x, cell.center().y), None, gpui::Modifiers::default());
cx.run_until_parked();
assert!(
cx.debug_bounds("table-row-handle").is_some(),
"handle disappeared at {x:?}; table {table:?}; cell {cell:?}"
);
x -= px(1.0);
}
let bounds = cx.debug_bounds("table-row-handle").unwrap();
assert!(bounds.left() >= table.left() && bounds.right() <= cell.left());
assert!((bounds.center().y - cell.center().y).abs() < px(1.0));
let handle = bounds.center();
cx.simulate_click(handle, gpui::Modifiers::default());
cx.run_until_parked();
let delete = cx
.debug_bounds("table-menu/Delete row")
.expect("delete row menu item");
cx.simulate_click(delete.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert!(!source(&editor, &mut cx).contains("first"));
}
#[gpui::test]
fn table_add_controls_fit_inside_the_block_without_covering_cells(cx: &mut TestAppContext) {
let (editor, mut cx) = open_scrolling_with(TABLE, cx);
cx.simulate_resize(size(px(360.0), px(600.0)));
cx.run_until_parked();
cx.simulate_mouse_move(point(px(50.0), px(30.0)), None, gpui::Modifiers::default());
cx.run_until_parked();
let (table, last) = cx.update(|_, cx| {
let layouts = editor.read(cx).layouts();
(
layouts.block_bounds(0).unwrap(),
layouts
.cell_bounds(0, markdown::Part::Cell { row: 1, column: 1 })
.unwrap(),
)
});
let add_row = cx.debug_bounds("table-add-row").expect("add row");
let add_column = cx.debug_bounds("table-add-column").expect("add column");
for bounds in [add_row, add_column] {
assert!(bounds.left() >= table.left() && bounds.right() <= table.right());
assert!(bounds.top() >= table.top() && bounds.bottom() <= table.bottom());
}
assert!(last.right() <= add_column.left());
assert!(last.bottom() <= add_row.top());
cx.simulate_click(add_row.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(
source(&editor, &mut cx),
"| a | b |\n| --- | --- |\n| c | d |\n| | |"
);
let add_column = cx.debug_bounds("table-add-column").unwrap();
cx.simulate_click(add_column.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(
source(&editor, &mut cx),
"| a | b | |\n| --- | --- | --- |\n| c | d | |\n| | | |"
);
}
#[gpui::test]
fn a_table_column_handle_survives_the_pointer_crossing_cell_padding(cx: &mut TestAppContext) {
let (editor, _, mut cx) = open_with(
"paragraph\n\n| A | B |\n| --- | --- |\n| first | second |",
cx,
);
let (table, cell) = cx.update(|_, cx| {
let layouts = editor.read(cx).layouts();
(
layouts.block_bounds(1).unwrap(),
layouts
.cell_bounds(1, markdown::Part::Cell { row: 0, column: 1 })
.unwrap(),
)
});
let mut y = cell.center().y;
while y >= table.top() {
cx.simulate_mouse_move(point(cell.center().x, y), None, gpui::Modifiers::default());
cx.run_until_parked();
assert!(cx.debug_bounds("table-column-handle").is_some());
y -= px(1.0);
}
let bounds = cx.debug_bounds("table-column-handle").unwrap();
assert!(
bounds.top() >= table.top() && bounds.bottom() <= cell.top(),
"handle {bounds:?}; table {table:?}; cell {cell:?}"
);
assert!((bounds.center().x - cell.center().x).abs() < px(1.0));
let handle = bounds.center();
cx.simulate_click(handle, gpui::Modifiers::default());
cx.run_until_parked();
let delete = cx
.debug_bounds("table-menu/Delete column")
.expect("delete column menu item");
cx.simulate_click(delete.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert!(!source(&editor, &mut cx).contains("second"));
}
#[gpui::test]
fn a_scrolled_table_cell_can_delete_its_row_or_column(cx: &mut TestAppContext) {
for label in ["table-menu/Delete row", "table-menu/Delete column"] {
let original = format!(
"| A | B |\n| --- | --- |\n{}",
(0..20)
.map(|row| format!("| a{row} | b{row} |"))
.collect::<Vec<_>>()
.join("\n")
);
let (editor, mut cx) = open_scrolling_with(&original, cx);
cx.simulate_resize(size(px(360.0), px(320.0)));
editor.update(&mut cx, |editor, cx| {
editor.select(
markdown::Selection::at(markdown::Cursor::new(
0,
markdown::Part::Cell { row: 12, column: 1 },
0,
)),
cx,
)
});
for _ in 0..8 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
let (table, cell, before) = cx.update(|_, cx| {
let editor = editor.read(cx);
(
editor.layouts().block_bounds(0).unwrap(),
editor
.layouts()
.cell_bounds(0, markdown::Part::Cell { row: 12, column: 1 })
.unwrap(),
editor.selection(),
)
});
assert!(table.top() < px(0.0), "the header is scrolled away");
cx.simulate_mouse_down(
cell.center(),
gpui::MouseButton::Right,
gpui::Modifiers::default(),
);
cx.simulate_mouse_up(
cell.center(),
gpui::MouseButton::Right,
gpui::Modifiers::default(),
);
cx.run_until_parked();
assert_eq!(
editor.read_with(&cx, |editor, _| editor.selection()),
before
);
let action = cx.debug_bounds(label).expect("cell context menu deletion");
cx.simulate_click(action.center(), gpui::Modifiers::default());
cx.run_until_parked();
let changed = source(&editor, &mut cx);
assert!(!changed.contains("b11"));
if label == "table-menu/Delete row" {
assert!(!changed.contains("a11"));
assert!(changed.contains("b12"));
} else {
assert!(changed.contains("a11"));
assert!(!changed.contains("b12"));
}
cx.simulate_keystrokes(&format!("{PRIMARY}-z"));
assert_eq!(source(&editor, &mut cx), original);
}
}
#[gpui::test]
fn table_moves_from_the_menu_are_undoable(cx: &mut TestAppContext) {
let original = "| A | B |\n| --- | --- |\n| one | first |\n| two | second |\n| three | third |";
for (selector, label, expected) in [
(
"table-row-handle",
"table-menu/Move down",
"| A | B |\n| --- | --- |\n| two | second |\n| one | first |\n| three | third |",
),
(
"table-column-handle",
"table-menu/Move right",
"| B | A |\n| --- | --- |\n| first | one |\n| second | two |\n| third | three |",
),
] {
let (editor, _, mut cx) = open_with(original, cx);
let cell = cx.update(|_, cx| {
editor
.read(cx)
.layouts()
.cell_bounds(0, markdown::Part::Cell { row: 1, column: 0 })
.unwrap()
});
cx.simulate_mouse_move(cell.center(), None, gpui::Modifiers::default());
cx.run_until_parked();
let handle = cx.debug_bounds(selector).unwrap();
cx.simulate_click(handle.center(), gpui::Modifiers::default());
cx.run_until_parked();
let action = cx.debug_bounds(label).expect("movement action");
cx.simulate_click(action.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(source(&editor, &mut cx), expected);
cx.simulate_keystrokes(&format!("{PRIMARY}-z"));
assert_eq!(source(&editor, &mut cx), original);
}
}
#[gpui::test]
fn dragging_table_handles_reorders_without_opening_the_menu(cx: &mut TestAppContext) {
let original = "| A | B |\n| --- | --- |\n| one | first |\n| two | second |\n| three | third |";
for row in [true, false] {
let (editor, _, mut cx) = open_with(original, cx);
let (first, last) = cx.update(|_, cx| {
let layouts = editor.read(cx).layouts();
(
layouts
.cell_bounds(0, markdown::Part::Cell { row: 1, column: 0 })
.unwrap(),
layouts
.cell_bounds(
0,
if row {
markdown::Part::Cell { row: 3, column: 0 }
} else {
markdown::Part::Cell { row: 1, column: 1 }
},
)
.unwrap(),
)
});
cx.simulate_mouse_move(first.center(), None, gpui::Modifiers::default());
cx.run_until_parked();
let handle = cx
.debug_bounds(if row {
"table-row-handle"
} else {
"table-column-handle"
})
.unwrap()
.center();
let target = if row {
point(handle.x, last.center().y)
} else {
point(last.center().x, handle.y)
};
cx.simulate_mouse_down(handle, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
cx.simulate_mouse_move(target, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
assert!(cx.debug_bounds("table-drop").is_some());
cx.simulate_mouse_up(target, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
let expected = if row {
"| A | B |\n| --- | --- |\n| two | second |\n| three | third |\n| one | first |"
} else {
"| B | A |\n| --- | --- |\n| first | one |\n| second | two |\n| third | three |"
};
assert_eq!(source(&editor, &mut cx), expected);
assert!(cx.debug_bounds("table-menu/Delete row").is_none());
assert!(cx.debug_bounds("table-menu/Delete column").is_none());
cx.simulate_keystrokes(&format!("{PRIMARY}-z"));
assert_eq!(source(&editor, &mut cx), original);
}
}
#[gpui::test]
fn table_reordering_carries_the_selection_and_anchors(cx: &mut TestAppContext) {
use markdown::{Cursor, Part, Selection};
let original = "| A | B |\n| --- | --- |\n| one | first |\n| two | second |\n| three | third |";
let (editor, _, mut cx) = open_with(original, cx);
let at = |row, column, offset| Cursor::new(0, Part::Cell { row, column }, offset);
let anchored = Selection::new(at(1, 0, 0), at(1, 0, 3));
let selected = Selection::at(at(2, 1, 2));
let document = editor.read_with(&cx, |editor, _| editor.document().clone());
document.update(&mut cx, |document, cx| {
document.set_anchors(vec![editor::Anchor::new(editor::AnchorId(1), anchored)], cx)
});
editor.update(&mut cx, |editor, cx| {
editor.select(selected, cx);
editor.move_row(0, 1, 3, cx);
assert_eq!(
editor.anchors()[0].range,
Selection::new(at(3, 0, 0), at(3, 0, 3))
);
assert_eq!(editor.selection(), Selection::at(at(1, 1, 2)));
editor.move_column(0, 0, 1, cx);
assert_eq!(
editor.anchors()[0].range,
Selection::new(at(3, 1, 0), at(3, 1, 3))
);
assert_eq!(editor.selection(), Selection::at(at(1, 0, 2)));
});
cx.simulate_keystrokes(&format!("{PRIMARY}-z {PRIMARY}-z"));
assert_eq!(source(&editor, &mut cx), original);
cx.update(|_, cx| {
assert_eq!(editor.read(cx).anchors()[0].range, anchored);
assert_eq!(editor.read(cx).selection(), selected);
});
}
#[gpui::test]
fn table_drags_can_be_cancelled(cx: &mut TestAppContext) {
let original = "| A | B |\n| --- | --- |\n| one | first |\n| two | second |";
for escape in [true, false] {
let (editor, _, mut cx) = open_with(original, cx);
let cell = cx.update(|_, cx| {
editor
.read(cx)
.layouts()
.cell_bounds(0, markdown::Part::Cell { row: 1, column: 0 })
.unwrap()
});
cx.simulate_mouse_move(cell.center(), None, gpui::Modifiers::default());
cx.run_until_parked();
let start = cx.debug_bounds("table-row-handle").unwrap().center();
cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
let to = if escape {
start + point(px(0.0), cell.size.height)
} else {
point(px(350.0), px(580.0))
};
cx.simulate_mouse_move(to, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
if escape {
cx.simulate_keystrokes("escape");
}
cx.simulate_mouse_up(to, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(source(&editor, &mut cx), original);
assert!(cx.debug_bounds("table-drop").is_none());
}
}
#[gpui::test]
fn a_right_press_on_text_opens_the_edit_menu(cx: &mut TestAppContext) {
let (editor, _window, mut cx) = open(cx);
let at = cx.update(|_, cx| {
let (at, line) = editor
.read(cx)
.layouts()
.position(
markdown::Cursor::new(1, markdown::Part::Body, 0),
markdown::Affinity::Downstream,
)
.unwrap();
at + point(px(1.0), line / 2.0)
});
cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default());
cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(
head(&editor, &mut cx).block,
1,
"the caret goes to the press"
);
let select_all = cx
.debug_bounds("text-menu/Select All")
.expect("the menu is open");
cx.simulate_click(select_all.center(), gpui::Modifiers::default());
cx.run_until_parked();
cx.update(|_, cx| {
let editor = editor.read(cx);
assert_eq!(
editor.selection(),
markdown::Selection::all(editor.doc()),
"Select All selected the document"
);
});
}
#[gpui::test]
fn a_shift_click_on_a_link_leaves_the_selection(cx: &mut TestAppContext) {
let (editor, _window, mut cx) =
open_with("Plain words, then [a link](https://example.com).", cx);
let at = |offset: usize, cx: &mut VisualTestContext| {
cx.update(|_, cx| {
let (at, line) = editor
.read(cx)
.layouts()
.position(
markdown::Cursor::new(0, markdown::Part::Body, offset),
markdown::Affinity::Downstream,
)
.unwrap();
at + point(px(1.0), line / 2.0)
})
};
let start = at(1, &mut cx);
cx.simulate_click(start, gpui::Modifiers::default());
cx.run_until_parked();
let before = cx.update(|_, cx| editor.read(cx).selection());
let link = at(20, &mut cx);
cx.simulate_click(link, gpui::Modifiers::shift());
cx.run_until_parked();
let after = cx.update(|_, cx| editor.read(cx).selection());
assert_eq!(after, before);
assert!(after.is_collapsed());
assert_eq!(cx.cx.opened_url().as_deref(), Some("https://example.com"));
}