Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 163 additions & 2 deletions crates/ui/src/markdown/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,13 +1232,23 @@ pub(crate) fn paint_text_selection(
text: &SharedString,
layout: &gpui::TextLayout,
theme: &Theme,
) {
paint_text_selection_with_wash(window, key, text, layout, selection_wash(theme));
}

fn paint_text_selection_with_wash(
window: &mut Window,
key: &std::sync::Arc<str>,
text: &SharedString,
layout: &gpui::TextLayout,
wash: Hsla,
) {
if let Some(range) = super::selection::wash_range(key) {
for rect in range_rects(layout, &range, 0.0, 0.0) {
window.paint_quad(quad(
rect,
px(0.0),
selection_wash(theme),
wash,
px(0.0),
gpui::transparent_black(),
BorderStyle::default(),
Expand All @@ -1256,6 +1266,33 @@ pub(crate) fn paint_text_selection(
register_selection_listeners(window, key, text, layout, None);
}

fn selectable_text_element(
key: std::sync::Arc<str>,
text: SharedString,
runs: Vec<TextRun>,
wash: Hsla,
) -> AnyElement {
let styled = StyledText::new(text.clone()).with_runs(runs);
let layout = styled.layout().clone();
let underlay = canvas(
|_, _, _| (),
move |_, _, window, _| {
paint_text_selection_with_wash(window, &key, &text, &layout, wash);
},
)
.absolute()
.size_full();
div()
.relative()
.child(underlay)
.child(styled)
.into_any_element()
}

fn code_line_selection_key(row_key: &str, code_ix: usize, line_ix: usize) -> std::sync::Arc<str> {
format!("{row_key}-code{code_ix}-line{line_ix}").into()
}

/// One painted text element, registered per frame in document order — the
/// continuity model that lets a drag span paragraphs/list items (Zed gets
/// this for free from its single-element markdown; our tree rebuilds it).
Expand Down Expand Up @@ -2069,6 +2106,7 @@ fn render_code_block_source_with_actions(
None => Vec::new(),
};
let scroll_id: SharedString = format!("{}-code{ix}", opts.row_key).into();
let sel_wash = selection_wash(theme);
let code_ui = opts.code.as_ref().and_then(|code| code.get(&ix)).cloned();
let fit_content = code_ui.as_ref().is_some_and(|ui| ui.fit_content);

Expand Down Expand Up @@ -2144,6 +2182,7 @@ fn render_code_block_source_with_actions(
*off = start + line.len() + 1; // +1 for the '\n'
let local = slice_spans(&veil_spans, start, start + line.len());
let runs = apply_veil(runs.clone(), &local);
let key = code_line_selection_key(&opts.row_key, ix, li);
Some(
div()
.map(|el| {
Expand All @@ -2153,7 +2192,7 @@ fn render_code_block_source_with_actions(
el.h(px(CODE_LINE_HEIGHT)).flex_none()
}
})
.child(StyledText::new(line.clone()).with_runs(runs)),
.child(selectable_text_element(key, line.clone(), runs, sel_wash)),
)
}));

Expand Down Expand Up @@ -2333,6 +2372,128 @@ pub fn runs_for_syntax_line_with_plain(
mod tests {
use super::*;
use crate::markdown::parser::{InlineStyle, parse_full};
use gpui::TestAppContext;

struct CodeSelectionHarness;

impl Render for CodeSelectionHarness {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
let opts = RenderOptions::settled("code-selection-test".into());
let plain = |text: &str| {
vec![InlineRun {
text: text.into(),
style: InlineStyle::default(),
}]
};
div()
.size_full()
.flex()
.flex_col()
.child(selection_frame_reset())
.child(text_element(
&plain("before"),
MD_TEXT_SIZE,
MD_LINE_HEIGHT,
false,
0,
0,
&opts,
&theme,
))
.child(render_code_block_source(
None,
"selectable\n\nsecond",
1,
1,
&opts,
&theme,
None,
))
.child(text_element(
&plain("after"),
MD_TEXT_SIZE,
MD_LINE_HEIGHT,
false,
2,
2,
&opts,
&theme,
))
}
}

#[gpui::test]
fn code_block_lines_participate_in_text_selection(cx: &mut TestAppContext) {
let _selection = super::super::selection::test_state_lock();
cx.update(|cx| cx.set_global(Theme::dark()));
let (_, cx) = cx.add_window_view(|_, _| CodeSelectionHarness);
cx.simulate_resize(size(px(640.0), px(240.0)));
cx.update(|window, cx| {
window.refresh();
let _ = window.draw(cx);
});

let before_key = "code-selection-test:0";
let first_key = "code-selection-test-code1-line0";
let blank_key = "code-selection-test-code1-line1";
let second_key = "code-selection-test-code1-line2";
let after_key = "code-selection-test:2";
let before_bounds = selection_test_bounds(before_key);
let first_bounds = selection_test_bounds(first_key);
selection_test_bounds(blank_key);
let second_bounds = selection_test_bounds(second_key);
let after_bounds = selection_test_bounds(after_key);

cx.simulate_event(gpui::MouseDownEvent {
button: gpui::MouseButton::Left,
position: first_bounds.origin + point(px(5.0), px(9.0)),
click_count: 2,
..Default::default()
});
assert_eq!(
super::super::selection::selected_text().as_deref(),
Some("selectable")
);
super::super::selection::end_active_drag();
super::super::selection::clear_if_owner(first_key);

cx.simulate_event(gpui::MouseDownEvent {
button: gpui::MouseButton::Left,
position: first_bounds.origin + point(px(1.0), px(9.0)),
click_count: 1,
..Default::default()
});
cx.simulate_event(gpui::MouseMoveEvent {
position: point(second_bounds.right(), second_bounds.top() + px(9.0)),
pressed_button: Some(gpui::MouseButton::Left),
..Default::default()
});
assert_eq!(
super::super::selection::selected_text().as_deref(),
Some("selectable\n\nsecond")
);
cx.simulate_event(gpui::MouseUpEvent {
button: gpui::MouseButton::Left,
position: point(second_bounds.right(), second_bounds.top() + px(9.0)),
..Default::default()
});
super::super::selection::clear_if_owner(first_key);

super::super::selection::begin(before_key, 0);
assert!(update_drag_at(point(
after_bounds.right(),
after_bounds.top() + px(9.0)
)));
assert_eq!(
super::super::selection::selected_text().as_deref(),
Some("before\nselectable\n\nsecond\nafter")
);
super::super::selection::end_active_drag();
super::super::selection::clear_if_owner(before_key);
assert!(before_bounds.top() < first_bounds.top());
assert!(second_bounds.bottom() < after_bounds.bottom());
}

#[test]
fn code_block_indices_include_nested_quotes_and_lists() {
Expand Down
38 changes: 24 additions & 14 deletions crates/ui/src/markdown/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ fn state() -> &'static Mutex<Option<MdSelection>> {
STATE.get_or_init(|| Mutex::new(None))
}

/// Selection state is process-global; tests that exercise its lifecycle must
/// not race each other.
#[cfg(test)]
pub(crate) fn test_state_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Resolve the spans for a selection between `a` and `b`, each an
/// `(element index, byte offset)` into `elements` (document-ordered
/// `(key, text)` pairs). Handles either direction; empty slices are skipped.
Expand All @@ -62,7 +71,10 @@ pub fn resolve_spans(elements: &[(&str, &str)], a: (usize, usize), b: (usize, us
let from = if ei == start.0 { start.1 } else { 0 };
let to = if ei == end.0 { end.1 } else { text.len() };
let (from, to) = (from.min(text.len()), to.min(text.len()));
if from < to {
// Keep empty elements strictly between the endpoints. Rendered code
// fences register one element per source line, so a blank line must
// contribute its newline when a selection crosses it.
if from < to || (ei > start.0 && ei < end.0) {
spans.push(Span {
key: (*key).to_string(),
range: from..to,
Expand Down Expand Up @@ -279,7 +291,6 @@ pub fn selected_text() -> Option<String> {
fn join_spans(spans: &[Span]) -> String {
spans
.iter()
.filter(|s| !s.range.is_empty())
.map(|s| &s.text[s.range.clone()])
.collect::<Vec<_>>()
.join("\n")
Expand Down Expand Up @@ -353,18 +364,17 @@ mod tests {
assert_eq!(resolve_spans(&elems(), (2, 5), (0, 6)), spans);
}

/// The drag tests below mutate the process-global selection state —
/// serialize them, or the parallel test runner interleaves their
/// begin/end_drag calls (long-standing flake).
fn state_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
#[test]
fn spans_across_empty_elements_preserve_blank_lines() {
let elements = [("line-1", "first"), ("line-2", ""), ("line-3", "third")];
let spans = resolve_spans(&elements, (0, 0), (2, 5));
assert_eq!(spans.len(), 3);
assert_eq!(join_spans(&spans), "first\n\nthird");
}

#[test]
fn drag_lifecycle_and_copy_joins() {
let _state = state_lock();
let _state = test_state_lock();
begin("p1", 6);
assert_eq!(drag_anchor("p1"), Some(6));
assert_eq!(drag_anchor("p2"), None);
Expand All @@ -384,7 +394,7 @@ mod tests {

#[test]
fn drag_survives_forward_virtualization() {
let _state = state_lock();
let _state = test_state_lock();
begin("p1", 6);
assert!(update_drag(&elems(), (2, 5)));
let shifted = [("p2", "second"), ("p3", "third one"), ("p4", "fourth")];
Expand All @@ -402,7 +412,7 @@ mod tests {

#[test]
fn drag_survives_backward_virtualization() {
let _state = state_lock();
let _state = test_state_lock();
begin("p5", 4);
let first = [("p3", "third"), ("p4", "fourth"), ("p5", "fifth")];
assert!(update_drag(&first, (0, 2)));
Expand All @@ -414,15 +424,15 @@ mod tests {

#[test]
fn empty_click_clears_on_release() {
let _state = state_lock();
let _state = test_state_lock();
begin("p1", 3);
assert_eq!(end_drag("p1"), None);
assert_eq!(selected_text(), None);
}

#[test]
fn double_click_span() {
let _state = state_lock();
let _state = test_state_lock();
begin_with_span("p1", "hello world", 6..11);
assert_eq!(wash_range("p1"), Some(6..11));
assert_eq!(end_drag("p1").as_deref(), Some("world"));
Expand Down
Loading