From 65fda35d5ad3b3eee041a33b5a3768a0e08a642a Mon Sep 17 00:00:00 2001 From: dcdyouget <1376372464@qq.com> Date: Mon, 14 Sep 2026 20:33:33 +0800 Subject: [PATCH] Fix text selection in code blocks --- crates/ui/src/markdown/render.rs | 165 +++++++++++++++++++++++++++- crates/ui/src/markdown/selection.rs | 38 ++++--- 2 files changed, 187 insertions(+), 16 deletions(-) diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index cf4fb2e41..04e6f82e8 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -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, + 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(), @@ -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, + text: SharedString, + runs: Vec, + 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 { + 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). @@ -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); @@ -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| { @@ -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)), ) })); @@ -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) -> 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() { diff --git a/crates/ui/src/markdown/selection.rs b/crates/ui/src/markdown/selection.rs index dec0a2b26..74639c573 100644 --- a/crates/ui/src/markdown/selection.rs +++ b/crates/ui/src/markdown/selection.rs @@ -48,6 +48,15 @@ fn state() -> &'static Mutex> { 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. @@ -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, @@ -279,7 +291,6 @@ pub fn selected_text() -> Option { fn join_spans(spans: &[Span]) -> String { spans .iter() - .filter(|s| !s.range.is_empty()) .map(|s| &s.text[s.range.clone()]) .collect::>() .join("\n") @@ -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); @@ -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")]; @@ -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))); @@ -414,7 +424,7 @@ 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); @@ -422,7 +432,7 @@ mod tests { #[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"));