use editor::{Anchor, AnchorId, Editor, SourceAnchor};
use gpui::{Entity, Focusable, TestAppContext, VisualTestContext, px, size};
use markdown::{Cursor, Part, Selection};
const SOURCE: &str = "alpha one\n\nbravo two\n\ncharlie three";
const ID: AnchorId = AnchorId(1);
#[cfg(target_os = "macos")]
const PRIMARY: &str = "cmd";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "ctrl";
fn open(cx: &mut TestAppContext) -> (Entity<Editor>, VisualTestContext) {
open_with(SOURCE, cx)
}
fn open_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| 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, visual)
}
fn at(block: usize, offset: usize) -> Cursor {
Cursor::new(block, Part::Body, offset)
}
fn anchor(
editor: &Entity<Editor>,
cx: &mut VisualTestContext,
range: (usize, usize, usize),
caret: Cursor,
) {
let (block, from, to) = range;
cx.update(|_, cx| {
let document = editor.read(cx).document().clone();
document.update(cx, |document, cx| {
document.set_anchors(
vec![Anchor::new(
ID,
Selection::new(at(block, from), at(block, to)),
)],
cx,
)
});
});
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.select(Selection::at(caret), cx)));
}
fn anchored(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> (usize, usize, usize) {
cx.update(|_, cx| {
let anchor = &editor.read(cx).anchors()[0];
let (start, end) = anchor.range.ordered();
(start.block, start.offset, end.offset)
})
}
fn detached(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> bool {
cx.update(|_, cx| editor.read(cx).anchors()[0].detached())
}
const ONE: (usize, usize, usize) = (0, 6, 9);
#[gpui::test]
fn typing_before_the_range_carries_it_along(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.simulate_input("XX");
assert_eq!(
anchored(&editor, &mut cx),
(0, 8, 11),
"both ends move by what went in ahead of them"
);
}
#[gpui::test]
fn typing_inside_the_range_widens_it(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 7));
cx.simulate_input("XX");
assert_eq!(
anchored(&editor, &mut cx),
(0, 6, 11),
"the start holds and the end gives way"
);
}
#[gpui::test]
fn typing_at_the_end_joins_the_comment(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 9));
cx.simulate_input("X");
assert_eq!(anchored(&editor, &mut cx), (0, 6, 10));
}
#[gpui::test]
fn typing_at_the_start_stays_outside_it(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 6));
cx.simulate_input("X");
assert_eq!(anchored(&editor, &mut cx), (0, 7, 10));
}
#[gpui::test]
fn typing_after_the_range_leaves_it_alone(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, (0, 0, 5), at(0, 9));
cx.simulate_input("XX");
assert_eq!(anchored(&editor, &mut cx), (0, 0, 5));
}
#[gpui::test]
fn typing_in_a_block_above_moves_nothing(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, (1, 0, 5), at(0, 0));
cx.simulate_input("XX");
assert_eq!(
anchored(&editor, &mut cx),
(1, 0, 5),
"an offset in another block is not an offset in this one"
);
}
#[gpui::test]
fn splitting_a_block_above_shifts_the_anchor_down(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, (1, 0, 5), at(0, 5));
cx.simulate_keystrokes("enter");
assert_eq!(
anchored(&editor, &mut cx),
(2, 0, 5),
"the anchored block is one further down"
);
}
#[gpui::test]
fn reordering_the_block_above_carries_the_anchor(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.move_block(0, 1, cx)));
assert_eq!(
anchored(&editor, &mut cx),
(1, 6, 9),
"the anchor rides the block it was on"
);
}
#[gpui::test]
fn removing_a_block_above_pulls_the_anchor_up(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, (1, 0, 5), at(0, 0));
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.remove_block(0, cx)));
assert_eq!(anchored(&editor, &mut cx), (0, 0, 5));
}
#[gpui::test]
fn removing_the_anchored_block_detaches_it(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.remove_block(0, cx)));
assert!(
detached(&editor, &mut cx),
"the words are gone, and the thread is kept to say so"
);
}
#[gpui::test]
fn deleting_the_anchored_words_detaches_it(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 6));
cx.update(|_, cx| {
editor.update(cx, |editor, cx| {
editor.select(Selection::new(at(0, 6), at(0, 9)), cx)
})
});
cx.simulate_keystrokes("backspace");
assert!(detached(&editor, &mut cx));
}
#[gpui::test]
fn undo_and_redo_restore_the_anchor_with_the_document(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.simulate_input("XX");
assert_eq!(anchored(&editor, &mut cx), (0, 8, 11));
cx.simulate_keystrokes(&format!("{PRIMARY}-z"));
assert_eq!(
anchored(&editor, &mut cx),
ONE,
"the anchor of that moment came back with the document"
);
cx.simulate_keystrokes(&format!("{PRIMARY}-shift-z"));
assert_eq!(
anchored(&editor, &mut cx),
(0, 8, 11),
"and redo replays it"
);
}
#[gpui::test]
fn a_click_finds_the_comment_under_it(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
let bounds = cx
.update(|_, cx| editor.read(cx).anchor_bounds(ID))
.expect("the anchor painted somewhere");
assert_eq!(
cx.update(|_, cx| editor.read(cx).anchor_at(bounds.origin)),
Some(ID),
"a point on the range answers with its thread"
);
}
#[gpui::test]
fn a_block_shortcut_takes_its_prefix_back_off_the_anchor(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.simulate_input("## ");
assert_eq!(
anchored(&editor, &mut cx),
ONE,
"the hashes went in and came out; the word they turned into a heading did not move"
);
}
#[gpui::test]
fn promoting_an_alert_takes_its_marker_off_the_anchor(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.simulate_input("> ");
cx.simulate_input("[!NOTE]");
cx.simulate_keystrokes("shift-enter");
assert_eq!(
anchored(&editor, &mut cx),
ONE,
"the marker and its break left the body they were written above"
);
}
#[gpui::test]
fn an_inline_rule_takes_its_delimiters_off_the_anchor(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
cx.simulate_input("**x**");
assert_eq!(
anchored(&editor, &mut cx),
(0, 7, 10),
"one bold letter is what is left ahead of the word"
);
}
fn anchored_text(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> String {
cx.update(|_, cx| {
let editor = editor.read(cx);
let (start, end) = editor.anchors()[0].range.ordered();
let text = editor.doc().blocks[start.block]
.text_at(start.part)
.unwrap();
text.text[start.offset..end.offset].to_string()
})
}
fn toggle_source(editor: &Entity<Editor>, cx: &mut VisualTestContext) {
cx.update(|_, cx| editor.update(cx, |editor, cx| editor.toggle_source(cx)));
cx.run_until_parked();
}
#[gpui::test]
fn an_anchor_follows_the_source_and_comes_back(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, (1, 6, 9), at(0, 0));
toggle_source(&editor, &mut cx);
assert_eq!(
anchored_text(&editor, &mut cx),
"two",
"the same words in the source"
);
cx.simulate_input("XX");
assert_eq!(
anchored_text(&editor, &mut cx),
"two",
"a source edit carries it along"
);
toggle_source(&editor, &mut cx);
assert_eq!(anchored(&editor, &mut cx), (1, 6, 9));
assert_eq!(
anchored_text(&editor, &mut cx),
"two",
"and back onto the blocks"
);
}
#[gpui::test]
fn an_anchor_end_in_the_markup_comes_back_detached(cx: &mut TestAppContext) {
let (editor, mut cx) = open_with("# Title", cx);
toggle_source(&editor, &mut cx);
let code = |offset| Cursor::new(0, Part::Code, offset);
cx.update(|_, cx| {
let document = editor.read(cx).document().clone();
document.update(cx, |document, cx| {
document.set_anchors(vec![Anchor::new(ID, Selection::new(code(1), code(7)))], cx)
})
});
toggle_source(&editor, &mut cx);
assert!(
detached(&editor, &mut cx),
"not stretched from the document start"
);
}
#[gpui::test]
fn anchors_cross_to_and_from_the_markdown(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
let bravo = SOURCE.find("bravo").unwrap();
cx.update(|_, cx| {
let document = editor.read(cx).document().clone();
document.update(cx, |document, cx| {
document.set_source_anchors(
SOURCE,
vec![SourceAnchor {
id: ID,
range: bravo..bravo + 5,
state: markdown::Annotation::Open,
actionable: true,
}],
cx,
)
})
});
assert_eq!(anchored(&editor, &mut cx), (1, 0, 5));
let actionable = cx.update(|_, cx| editor.read(cx).anchors()[0].actionable);
assert!(actionable, "the flag comes through with the range");
let (source, ranges) =
cx.update(|_, cx| editor.read(cx).document().read(cx).source_with_anchors());
assert_eq!(source, SOURCE);
assert_eq!(ranges, vec![(ID, Some(bravo..bravo + 5))]);
}
#[gpui::test]
fn anchor_tops_follow_the_document_down(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
let (alpha, charlie) = (
SOURCE.find("alpha").unwrap(),
SOURCE.find("charlie").unwrap(),
);
cx.update(|_, cx| {
let document = editor.read(cx).document().clone();
document.update(cx, |document, cx| {
document.set_source_anchors(
SOURCE,
vec![
SourceAnchor {
id: AnchorId(2),
range: charlie..charlie + 7,
state: markdown::Annotation::Open,
actionable: false,
},
SourceAnchor {
id: ID,
range: alpha..alpha + 5,
state: markdown::Annotation::Open,
actionable: false,
},
],
cx,
)
})
});
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
let tops = cx.update(|_, cx| editor.read(cx).anchor_tops());
let ids: Vec<_> = tops.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![ID, AnchorId(2)], "in document order");
assert!(tops[0].1 < tops[1].1, "{tops:?}");
}
#[gpui::test]
fn washes_leave_the_anchors_alone(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
let hit = Selection::new(at(0, 0), at(0, 5));
cx.update(|_, cx| {
editor.update(cx, |editor, cx| {
editor.set_washes(vec![(hit, markdown::Annotation::Match)], cx)
})
});
cx.update(|_, cx| {
assert!(editor.read(cx).anchors().is_empty());
assert_eq!(
editor.read(cx).washes(),
&[(hit, markdown::Annotation::Match)]
);
});
}
#[gpui::test]
fn the_pointer_over_anchored_text_names_the_anchor(cx: &mut TestAppContext) {
let (editor, mut cx) = open(cx);
anchor(&editor, &mut cx, ONE, at(0, 0));
let heard = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update(|_, cx| {
let heard = heard.clone();
cx.subscribe(&editor, move |_, event: &editor::EditorEvent, _| {
if let editor::EditorEvent::AnchorHovered(over) = event {
heard.borrow_mut().push(*over);
}
})
.detach();
});
let bounds = cx
.update(|_, cx| editor.read(cx).anchor_bounds(ID))
.expect("the anchor painted somewhere");
let on = bounds.origin + gpui::point(px(2.0), bounds.size.height / 2.0);
cx.simulate_mouse_move(on, None, gpui::Modifiers::none());
cx.run_until_parked();
assert_eq!(heard.borrow().as_slice(), &[Some(ID)]);
}