From f91c88261ca750c3a421fa0bea3a0fbb3db91a51 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 10:25:00 +0800 Subject: [PATCH] Stabilize Markdown editing, selection and popup geometry --- Cargo.lock | 6 +- Cargo.toml | 7 +- ...09-21-native-pointer-and-layout-handoff.md | 98 +++++++++++++++ .../gpui_shell/file_editor/code_actions.rs | 55 +++++---- .../file_editor/code_actions_tests.rs | 58 +++++++++ .../file_editor/inline_selection_tests.rs | 60 +++++++++ .../src/gpui_shell/file_editor/live_edit.rs | 116 ++++++++++-------- .../src/gpui_shell/file_editor/live_tests.rs | 82 +++++++++++++ nebula_app/src/gpui_shell/file_editor/mod.rs | 2 - .../src/gpui_shell/file_editor/preview.rs | 37 +++--- .../file_editor/reader_presentation.rs | 9 ++ .../file_editor/structure_inline_view.rs | 7 +- .../gpui_shell/workspace/window_titlebar.rs | 56 ++++++++- 13 files changed, 490 insertions(+), 103 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-21-native-pointer-and-layout-handoff.md diff --git a/Cargo.lock b/Cargo.lock index 5082a43..36cbb3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/Kuddev/gpui-component?rev=e411cc06dfff83a96c4705076483a87a26ea992d#e411cc06dfff83a96c4705076483a87a26ea992d" +source = "git+https://github.com/Kuddev/gpui-component?rev=f46ecbb06de5907af9700f3f6e2b5b0845459188#f46ecbb06de5907af9700f3f6e2b5b0845459188" dependencies = [ "aho-corasick", "anyhow", @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=e411cc06dfff83a96c4705076483a87a26ea992d#e411cc06dfff83a96c4705076483a87a26ea992d" +source = "git+https://github.com/Kuddev/gpui-component?rev=f46ecbb06de5907af9700f3f6e2b5b0845459188#f46ecbb06de5907af9700f3f6e2b5b0845459188" dependencies = [ "anyhow", "gpui", @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=e411cc06dfff83a96c4705076483a87a26ea992d#e411cc06dfff83a96c4705076483a87a26ea992d" +source = "git+https://github.com/Kuddev/gpui-component?rev=f46ecbb06de5907af9700f3f6e2b5b0845459188#f46ecbb06de5907af9700f3f6e2b5b0845459188" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index e20f1be..51a904a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,5 +184,8 @@ winit = { path = "third_party/winit-0.30.13" } # # 当前精确引用包含补丁 1-3、6 及字体内存修补;Input 补丁 4-5 仍保持独立。 gpui = { git = "https://github.com/Kuddev/zed", rev = "7c662384cd77c1d1e1566206920e3a420ea6767b", version = "=0.2.2" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "e411cc06dfff83a96c4705076483a87a26ea992d", version = "=0.5.2" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "e411cc06dfff83a96c4705076483a87a26ea992d", version = "=0.5.1" } +# Native inputs share rendered text's device-pixel line height and expose a +# post-paint point lookup. Window selection ends on release even when a child +# consumes MouseUp or the platform reports the next move without a held button. +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "f46ecbb06de5907af9700f3f6e2b5b0845459188", version = "=0.5.2" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "f46ecbb06de5907af9700f3f6e2b5b0845459188", version = "=0.5.1" } diff --git a/architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-21-native-pointer-and-layout-handoff.md b/architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-21-native-pointer-and-layout-handoff.md new file mode 100644 index 0000000..9637490 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-21-native-pointer-and-layout-handoff.md @@ -0,0 +1,98 @@ +# Native pointer ownership and reader-to-input layout + +## Status + +Implemented. + +## Context + +The reader creates one native input when a Markdown block is activated. The +first click must retain its location through that transition. Window dragging, +text dragging and the reader's edge-scroll task also share pointer events but +have different owners. + +## Evidence + +- A next-frame callback runs before the newly mounted input has painted. Missing + glyph bounds previously fell back to offset zero, moving a first click to the + beginning of the block. +- The window selection controller previously continued a drag on mouse movement + without checking the held button. A missing or consumed release therefore + left selection active. The reader's separate edge-scroll task needed the same + release boundary. +- The outer reader's padding moved its recorded bottom below the visible + viewport. The virtual list already owns the correct scroll viewport bounds. +- GPUI rendered text snaps line height to device pixels, while the native input + used whole logical pixels. The regression reproduced a 0.5 logical pixel shift + below the first three heading levels at 200% scale even with matching padding. +- A list's maximum height bounds its result area. Search, border and padding + must fit in the language popup's remaining viewport space as well. + +## Decision + +- Keep the canonical Markdown buffer, virtual reader and single active input. + Retain one bounded pending click, and resolve it after that input paints using + its native UTF-8 point lookup. The input identity, content, selection, focus and + IME range guard against an obsolete click overwriting newer input. +- Keep window selection release in the component's shared controller. Capture a + left-button release before child handlers, and end a drag on movement without + a held left button. Preserve the completed selection for copying. +- Give title-bar presses to window dragging. Stop the reader's owned edge-scroll + task at the same release boundary, including when the pointer is outside the + reader or a child consumes the event. + Use the virtual list's viewport for edge detection instead of maintaining a + second geometry snapshot of the padded outer container. +- Use the rendered text's device-pixel line-height rule for native input layout, + painting and ghost text. Match heading weight and bottom spacing across reader + and editor presentations, including headings with embedded inline objects. +- Anchor the language popup below its trigger, constrain it to the window with + an eight-pixel margin, and reserve space for its search field and padding. + Resize preserves keyboard search, selection and dismissal. +- Pin the component changes by exact revision in the workspace manifest and + lockfile. A local path override is only a development aid. + +## Rejected alternatives + +- Guessing columns or scanning every character's bounds duplicates native hit + testing and confuses absent geometry with a valid offset. +- Disabling Markdown selection hides the release defect and breaks normal + dragging and copying. +- An application-only movement filter leaves the shared selection controller + active, affecting other text surfaces and subsequent input. +- Enlarging the layout-test tolerance conceals a real reader/editor mismatch. +- Replacing the native editor, keeping inputs for every block, or adding another + document model is unnecessary for these ownership and geometry defects. + +## Consequences + +The post-paint handoff is active only for the newly mounted input. It retains no +second document and no per-character geometry table. Existing preview, active +block, image and history budgets remain in force. The line-height correction +also applies to other native inputs at fractional scale factors; their tests +must remain part of validation. + +## Validation + +The regressions exercise actual GPUI layout and input dispatch: first-click +glyph placement with CJK and emoji, composition before initial layout, local +source reveal, normal and interrupted selection, edge scrolling, title-bar +dragging, all six heading levels at four display scales, and a language popup +that remains searchable and selectable after a window resize. + +On Windows, the isolated application candidate passed all 91 file-editor tests, +the title-bar interaction regression and 67 settings tests. The component patch +passed 23 window-selection tests and 106 native-input tests. The independent +internationalization contract passed 20 tests with one existing ignored test; +its ordinary translation lookup allocation check remains green. The architecture +checker and its 53 regression tests passed. These tests do not constitute a +claim about other platforms or a total process memory limit. + +## Supersedes + +None. The existing bounded inline-source-reveal decision still applies. + +## Revisit when + +The component supplies a post-layout activation API or upstream adopts the +pointer-release and device-pixel input behavior. Remove the fork-specific patch +when an exact upstream revision passes the same interaction regressions. diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions.rs b/nebula_app/src/gpui_shell/file_editor/code_actions.rs index 648ced8..65c5013 100644 --- a/nebula_app/src/gpui_shell/file_editor/code_actions.rs +++ b/nebula_app/src/gpui_shell/file_editor/code_actions.rs @@ -306,25 +306,34 @@ fn render_language_picker( if !open { return picker; } + let viewport = window.viewport_size(); + let popup_width = px(224.0).min((viewport.width - px(16.0)).max(px(1.0))); + let popup_height = px(280.0).min((viewport.height - px(16.0)).max(px(1.0))); + // List 的 max_h 只约束结果列表,搜索框和外层边距需要单独留出空间。 + let rows_height = (popup_height - px(48.0)).max(px(0.0)); picker.child( deferred( - anchored().snap_to_window_with_margin(px(8.0)).child( - div() - .id("markdown-language-popup") - .debug_selector(|| "markdown-language-popup".to_owned()) - .occlude() - .w(px(224.0)) - .max_h(px(280.0)) - .bg(colors.popup) - .text_color(colors.ink) - .border_1() - .border_color(colors.line) - .rounded(px(6.0)) - .shadow_lg() - .p(px(6.0)) - .child( - div().debug_selector(|| "markdown-language-search".to_owned()).child( - List::new(&list) + anchored() + .offset(gpui::point(px(0.0), px(28.0))) + .snap_to_window_with_margin(px(8.0)) + .child( + div() + .id("markdown-language-popup") + .debug_selector(|| "markdown-language-popup".to_owned()) + .occlude() + .w(popup_width) + .max_h(popup_height) + .overflow_hidden() + .bg(colors.popup) + .text_color(colors.ink) + .border_1() + .border_color(colors.line) + .rounded(px(6.0)) + .shadow_lg() + .p(px(6.0)) + .child( + div().debug_selector(|| "markdown-language-search".to_owned()).child( + List::new(&list) // Small also shrinks the query field. The // approved prototype gives the search // field and every menu row independent @@ -332,14 +341,14 @@ fn render_language_picker( .with_size(Size::Medium) .search_placeholder(search_placeholder) .scrollbar_visible(false) - .max_h(px(264.0)) + .max_h(rows_height) .text_color(colors.ink), + ), + ) + .on_mouse_down_out( + window.listener_for(&state, CodeLanguage::close_from_outside), ), - ) - .on_mouse_down_out( - window.listener_for(&state, CodeLanguage::close_from_outside), - ), - ), + ), ) .with_priority(1), ) diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs index 4eadef9..bf278a7 100644 --- a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs @@ -56,6 +56,11 @@ fn picker_trigger_popup_search_and_rows_keep_html_geometry(cx: &mut TestAppConte let _ = window.draw(cx); }); assert!(cx.debug_bounds("markdown-language-popup").is_some()); + let popup = cx.debug_bounds("markdown-language-popup").unwrap(); + assert!( + popup.top() >= trigger.bottom(), + "the popup opens below its trigger when space permits" + ); assert!(cx.debug_bounds("markdown-language-search").unwrap().size.height >= px(32.0)); cx.simulate_input("rust"); cx.run_until_parked(); @@ -65,6 +70,59 @@ fn picker_trigger_popup_search_and_rows_keep_html_geometry(cx: &mut TestAppConte assert!(cx.debug_bounds("markdown-language-option-rust").is_some()); } +#[gpui::test] +fn language_picker_fits_a_resized_window_and_keeps_search_usable(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("picker-edges.md"); + std::fs::write(&path, "```text\nlet x = 42;\n```\n").unwrap(); + let (file, mut cx) = open(path, cx); + cx.update(|_, cx| { + file.update(cx, |view, cx| { + view.show_details = false; + cx.notify(); + }) + }); + cx.simulate_resize(gpui::size(px(420.0), px(320.0))); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let trigger = cx.debug_bounds("markdown-language-picker").unwrap(); + cx.simulate_click(trigger.center(), Modifiers::default()); + cx.run_until_parked(); + + for (width, height) in [(420.0, 320.0), (320.0, 220.0)] { + cx.simulate_resize(gpui::size(px(width), px(height))); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + let popup = cx.debug_bounds("markdown-language-popup").expect("open popup survives resize"); + assert!(popup.left() >= px(8.0) && popup.right() <= px(width - 8.0), "{popup:?}"); + assert!(popup.top() >= px(8.0) && popup.bottom() <= px(height - 8.0), "{popup:?}"); + let search = cx.debug_bounds("markdown-language-search").unwrap(); + assert!( + search.top() >= popup.top() && search.bottom() <= popup.bottom(), + "{search:?} outside {popup:?}" + ); + } + + cx.simulate_input("rust"); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let option = cx.debug_bounds("markdown-language-option-rust").unwrap(); + let popup = cx.debug_bounds("markdown-language-popup").unwrap(); + assert!(option.top() >= popup.top() && option.bottom() <= popup.bottom()); + cx.simulate_click(option.center(), Modifiers::default()); + cx.run_until_parked(); + assert!(file.read_with(&cx, |view, cx| view.draft(cx).starts_with("```rust\n"))); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert!(cx.debug_bounds("markdown-language-popup").is_none()); +} + #[gpui::test] fn code_copy_feedback_keeps_control_visible_after_pointer_leaves(cx: &mut TestAppContext) { let directory = tempfile::tempdir().unwrap(); diff --git a/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs b/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs index 76de3ff..39d7a83 100644 --- a/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs @@ -85,3 +85,63 @@ fn dragging_across_inline_fragments_selects_reading_text_without_entering_live_e cx.run_until_parked(); assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), "Before bold $x$ italic after"); } + +#[gpui::test] +fn missing_mouse_up_does_not_extend_selection_on_hover(cx: &mut TestAppContext) { + let (_directory, file, mut cx) = open("First paragraph with text\n\nSecond paragraph", cx); + draw(&mut cx); + let first = cx.debug_bounds("markdown-preview-block-0").unwrap(); + let second = cx.debug_bounds("markdown-preview-block-1").unwrap(); + let start = gpui::point(first.left() + px(1.0), first.top() + px(12.0)); + let end = gpui::point(first.left() + px(65.0), start.y); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + draw(&mut cx); + cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + draw(&mut cx); + let selected = cx.update(|window, cx| window.selected_text(cx).to_string()); + assert!(!selected.is_empty()); + assert!(file.read_with(&cx, |view, _| view.preview_selection_scroll_active)); + cx.simulate_mouse_move(second.center(), None, Modifiers::default()); + draw(&mut cx); + assert_eq!(cx.update(|window, cx| window.selected_text(cx).to_string()), selected); + assert!(!file.read_with(&cx, |view, _| view.preview_selection_scroll_active)); +} + +#[gpui::test] +fn reader_edge_scroll_stops_after_a_missing_release(cx: &mut TestAppContext) { + let source = + (0..80).map(|i| format!("Paragraph {i} has selectable text.\n\n")).collect::(); + let (_directory, file, mut cx) = open(&source, cx); + draw(&mut cx); + let first = cx.debug_bounds("markdown-preview-block-0").unwrap(); + let viewport = cx.debug_bounds("markdown-preview-viewport").unwrap(); + let start = gpui::point(first.left() + px(1.0), first.top() + px(12.0)); + let edge = gpui::point(start.x + px(80.0), viewport.bottom() - px(2.0)); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + draw(&mut cx); + cx.simulate_mouse_move(edge, Some(MouseButton::Left), Modifiers::default()); + draw(&mut cx); + assert!(file.read_with(&cx, |view, _| view.preview_selection_scroll_active)); + cx.update(|window, cx| { + assert!(window.has_text_selection(cx), "the edge drag must select rendered text"); + assert_eq!(window.mouse_position(), edge); + }); + let before = file.read_with(&cx, |view, _| view.scroll.scroll_px_offset_for_scrollbar()); + cx.executor().advance_clock(std::time::Duration::from_millis(50)); + cx.run_until_parked(); + draw(&mut cx); + let during = file.read_with(&cx, |view, _| view.scroll.scroll_px_offset_for_scrollbar()); + assert_ne!(before, during, "a held drag at {edge:?} must scroll the viewport {viewport:?}"); + + cx.simulate_mouse_move(edge, None, Modifiers::default()); + draw(&mut cx); + let stopped = file.read_with(&cx, |view, _| view.scroll.scroll_px_offset_for_scrollbar()); + cx.executor().advance_clock(std::time::Duration::from_millis(200)); + cx.run_until_parked(); + draw(&mut cx); + assert_eq!( + file.read_with(&cx, |view, _| view.scroll.scroll_px_offset_for_scrollbar()), + stopped + ); + assert!(!file.read_with(&cx, |view, _| view.preview_selection_scroll_active)); +} diff --git a/nebula_app/src/gpui_shell/file_editor/live_edit.rs b/nebula_app/src/gpui_shell/file_editor/live_edit.rs index 49154d1..cb1c47b 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_edit.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_edit.rs @@ -19,11 +19,18 @@ pub(super) struct LiveEdit { pub(super) last_selection: Range, changed: bool, heading: Option, + pending_click: Option, pub(super) decorations: TextDecorationCollection, _subscription: Subscription, _observation: Subscription, } +struct PendingClick { + position: Point, + text: SharedString, + selection: Range, +} + pub(super) fn decorations(projection: &Projection, cx: &App) -> Vec { projection .marks() @@ -200,6 +207,11 @@ impl TextFileView { kind, changed: false, heading, + pending_click: click.map(|click| PendingClick { + position: click.position(), + text: input.read(cx).value(), + selection: input.read(cx).selected_range(), + }), decorations: collection, _subscription: subscription, _observation: observation, @@ -208,60 +220,38 @@ impl TextFileView { self.stop_preview_selection_scroll(); self.invalidate_live_block(block); input.update(cx, |input, cx| input.focus(window, cx)); - if let Some(click) = click { - let head = click.position(); - let anchor = match click { - gpui::ClickEvent::Mouse(click) => click.down.position, - _ => head, - }; - // Use the native input's actual wrapped glyph geometry after layout, - // including proportional fonts and CJK, instead of guessing columns. - let initial_text = input.read(cx).value(); - let initial_selection = input.read(cx).selected_range(); - cx.on_next_frame(window, move |view, window, cx| { - if !view.live_edit.as_ref().is_some_and(|edit| edit.input == input) { - return; - } - input.update(cx, |input, cx| { - // Input arriving before layout owns the caret, especially - // an IME composition or a subsequent keyboard selection. - if input.value() != initial_text - || input.selected_range() != initial_selection - || input.marked_text_range(window, cx).is_some() - || !input.focus_handle(cx).is_focused(window) - { - return; - } - let text = input.value(); - let closest = |point: Point| { - text.char_indices() - .map(|(offset, _)| offset) - .chain([text.len()]) - .filter_map(|offset| { - let bounds = input.range_to_bounds(&(offset..offset))?; - let dy = if point.y < bounds.top() { - bounds.top() - point.y - } else if point.y > bounds.bottom() { - point.y - bounds.bottom() - } else { - px(0.0) - }; - let dx = f32::from(bounds.left() - point.x).abs(); - Some((offset, f32::from(dy) * 10000.0 + dx)) - }) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(offset, _)| offset) - .unwrap_or(0) - }; - let start = closest(anchor); - let end = closest(head); - input.set_selected_range(start.min(end)..start.max(end), cx); - }) - }); - } cx.notify(); } + fn finish_live_click( + &mut self, + input: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(edit) = self.live_edit.as_mut().filter(|edit| edit.input == *input) else { + return; + }; + let Some(click) = edit.pending_click.take() else { return }; + let consumed = input.update(cx, |input, cx| { + // 布局前到达的输入、选区或 IME 拥有光标,延后的首次点击不能覆盖它们。 + if edit.changed + || input.text().slice(..) != click.text.as_ref() + || input.selected_range() != click.selection + || input.marked_text_range(window, cx).is_some() + || !input.focus_handle(cx).is_focused(window) + { + return true; + } + let Some(offset) = input.offset_for_point(click.position) else { return false }; + input.set_selected_range(offset..offset, cx); + true + }); + if !consumed { + edit.pending_click = Some(click); + } + } + pub(super) fn update_live_edit(&mut self, window: &mut Window, cx: &mut Context) { let Some(edit) = self.live_edit.as_mut() else { return }; // Focus, selection and cursor blink notify the same entity. Compare its @@ -396,6 +386,7 @@ impl TextFileView { .relative() .w_full() .min_w_0() + .when(edit.heading.is_some() && edit.part.is_none(), |block| block.pb(gpui::rems(0.3))) .child( Input::new(&edit.input) .appearance(false) @@ -408,7 +399,9 @@ impl TextFileView { .font_family(cx.theme().font_family.clone()) .text_size(px(reader_presentation::heading_size(edit.heading))) .line_height(gpui::relative(reader_presentation::LINE_HEIGHT)) - .when(edit.heading.is_some(), |input| input.font_semibold()) + .when(edit.heading.is_some(), |input| { + input.font_weight(reader_presentation::heading_weight(edit.heading)) + }) .when(code || math, |input| { input .font_family(cx.theme().mono_font_family.clone()) @@ -464,6 +457,25 @@ impl TextFileView { ) }, ) + .when(edit.pending_click.is_some(), |block| { + let owner = cx.weak_entity(); + let input = edit.input.clone(); + block.child( + gpui::canvas( + |_, _, _| (), + move |_, _, window, cx| { + // Input 在 paint 阶段保存字形位置;下一帧回调早于布局,不能用来交接点击。 + window.defer(cx, move |window, cx| { + let _ = owner.update(cx, |view, cx| { + view.finish_live_click(&input, window, cx); + }); + }); + }, + ) + .absolute() + .inset_0(), + ) + }) .into_any_element() } } diff --git a/nebula_app/src/gpui_shell/file_editor/live_tests.rs b/nebula_app/src/gpui_shell/file_editor/live_tests.rs index 0e19a69..9ebab84 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_tests.rs @@ -217,3 +217,85 @@ fn clicking_near_the_start_places_the_caret_near_the_start(cx: &mut TestAppConte file.read_with(&cx, |view, cx| view.live_edit.as_ref().unwrap().input.read(cx).cursor()); assert!(caret <= 1, "click at {point:?}, block {bounds:?}, actual caret {caret}"); } + +#[gpui::test] +fn first_click_uses_painted_glyphs_after_the_frame_callback(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("first-click.md"); + let source = "中文🌿 mixed text with a long tail for positioning."; + std::fs::write(&path, source).unwrap(); + let (file, mut cx) = tests::open(path, cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let bounds = cx.debug_bounds("markdown-preview-block-0").unwrap(); + let point = gpui::point(bounds.left() + px(108.0), bounds.top() + px(14.0)); + cx.simulate_mouse_down(point, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::default()); + cx.update(|window, cx| { + // 真实刷新先执行下一帧回调,再绘制刚刚挂载的 Input。 + window.simulate_next_frame(cx); + let _ = window.draw(cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + file.read_with(&cx, |view, cx| { + let input = view.live_edit.as_ref().unwrap().input.read(cx); + let caret = input.cursor(); + assert!(caret > 0 && caret < source.len(), "unexpected caret: {caret}"); + assert!(source.is_char_boundary(caret)); + let caret_bounds = input.range_to_bounds(&(caret..caret)).unwrap(); + assert!( + f32::from(caret_bounds.left() - point.x).abs() <= 12.0, + "click {point:?}, caret {caret_bounds:?}" + ); + }); +} + +#[gpui::test] +fn activating_heading_preserves_following_paragraph_position(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let mut positions = Vec::new(); + for (scale, level) in + [1.0, 1.25, 1.5, 2.0].into_iter().flat_map(|scale| (1..=6).map(move |level| (scale, level))) + { + let path = directory.path().join(format!("heading-{scale}-{level}.md")); + std::fs::write(&path, format!("{} A heading\n\nFollowing paragraph", "#".repeat(level))) + .unwrap(); + let (file, mut visual) = tests::open(path, cx); + visual.update(|window, cx| { + window.set_scale_factor(scale); + file.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + let before = visual.debug_bounds("markdown-preview-block-1").unwrap().top(); + visual.update(|window, cx| { + file.update(cx, |view, cx| view.begin_live_edit(0, window, cx)); + let _ = window.draw(cx); + }); + visual.run_until_parked(); + visual.update(|window, cx| { + let _ = window.draw(cx); + }); + let during = visual.debug_bounds("markdown-preview-block-1").unwrap().top(); + let live = visual.debug_bounds("markdown-live-block").unwrap(); + let line_height = file.read_with(&visual, |view, cx| { + view.live_edit.as_ref().unwrap().input.read(cx).line_height() + }); + visual.update(|window, cx| { + file.update(cx, |view, cx| view.finish_live_edit(cx)); + let _ = window.draw(cx); + }); + let after = visual.debug_bounds("markdown-preview-block-1").unwrap().top(); + positions.push((scale, level, before, during, after, live, line_height)); + } + assert!( + positions + .iter() + .all(|(_, _, before, during, after, _, _)| f32::from(*before - *during).abs() <= 0.1 + && f32::from(*before - *after).abs() <= 0.1), + "heading activation changed document geometry: {positions:#?}" + ); +} diff --git a/nebula_app/src/gpui_shell/file_editor/mod.rs b/nebula_app/src/gpui_shell/file_editor/mod.rs index 5ee544b..87ddc82 100644 --- a/nebula_app/src/gpui_shell/file_editor/mod.rs +++ b/nebula_app/src/gpui_shell/file_editor/mod.rs @@ -130,7 +130,6 @@ pub struct TextFileView { preview_extensions: gpui_component::text::MarkdownExtensions, preview_images: Entity, scroll: ListState, - preview_bounds: Rc>>, preview_selection_scroll_epoch: u64, preview_selection_scroll_active: bool, outline_scroll: ScrollHandle, @@ -257,7 +256,6 @@ impl TextFileView { blocks: Rc::default(), inline_views: Rc::default(), scroll: ListState::new(0, ListAlignment::Top, px(500.0)), - preview_bounds: Rc::default(), preview_selection_scroll_epoch: 0, preview_selection_scroll_active: false, outline_scroll: ScrollHandle::new(), diff --git a/nebula_app/src/gpui_shell/file_editor/preview.rs b/nebula_app/src/gpui_shell/file_editor/preview.rs index 8083e9f..4b14673 100644 --- a/nebula_app/src/gpui_shell/file_editor/preview.rs +++ b/nebula_app/src/gpui_shell/file_editor/preview.rs @@ -210,6 +210,7 @@ fn block_frame(index: usize, heading: Option) -> gpui::Stateful { .max_w(px(reader_presentation::PAGE_WIDTH)) .mx_auto() .min_w_0() + .min_h(px(32.0)) .pt(px(if index > 0 && heading.is_some() { 20.0 } else { 4.0 })) .pb(px(10.0)) .debug_selector(move || format!("markdown-preview-block-{index}")) @@ -274,7 +275,7 @@ impl TextFileView { if epoch != self.preview_selection_scroll_epoch || !self.preview_selection_scroll_active { return None; } - let bounds = *self.preview_bounds.borrow(); + let bounds = self.scroll.viewport_bounds(); let Some(delta) = selection_scroll_delta( window.has_text_selection(cx), window.mouse_position().y, @@ -300,9 +301,9 @@ impl TextFileView { let retained_blocks = self.blocks.clone(); let inline_views = self.inline_views.clone(); let owner = cx.entity().downgrade(); + let selection_owner = owner.clone(); let extensions = self.preview_extensions.clone(); let scroll = self.scroll.clone(); - let bounds = self.preview_bounds.clone(); let live_mode = self.live_mode; let style = TextViewStyle { image_base: self.path.parent().map(Arc::from), @@ -337,25 +338,12 @@ impl TextFileView { .px(px(reader_presentation::PAGE_MARGIN)) .pt(px(reader_presentation::TOP_MARGIN)) .debug_selector(|| "markdown-preview-viewport".to_owned()) - .on_prepaint(move |viewport, _, _| *bounds.borrow_mut() = viewport) .on_mouse_down( MouseButton::Left, cx.listener(|this, _, window, cx| { this.start_preview_selection_scroll(window, cx); }), ) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, _, _| { - this.stop_preview_selection_scroll(); - }), - ) - .on_mouse_up_out( - MouseButton::Left, - cx.listener(|this, _, _, _| { - this.stop_preview_selection_scroll(); - }), - ) .on_mouse_down( MouseButton::Right, cx.listener(|_, event: &gpui::MouseDownEvent, window, cx| { @@ -416,7 +404,6 @@ impl TextFileView { let edit_owner = owner.clone(); let link_owner = owner.clone(); block_frame(index, heading) - .min_h(px(32.0)) .when(live_mode, |block| { block.cursor_text().on_click(move |event, window, cx| { let _ = edit_owner.update(cx, |file, cx| { @@ -474,7 +461,23 @@ impl TextFileView { } } }, - |_, _, _, _| {}, + move |_, _, window, _| { + let owner = selection_owner.clone(); + window.on_mouse_event(move |event: &gpui::MouseMoveEvent, phase, _, cx| { + if phase.capture() && event.pressed_button != Some(MouseButton::Left) { + let _ = owner + .update(cx, |view, _| view.stop_preview_selection_scroll()); + } + }); + let owner = selection_owner.clone(); + window.on_mouse_event(move |event: &gpui::MouseUpEvent, phase, _, cx| { + // A child can consume release, and the pointer can leave the reader. + if phase.capture() && event.button == MouseButton::Left { + let _ = owner + .update(cx, |view, _| view.stop_preview_selection_scroll()); + } + }); + }, ) .absolute() .inset_0(), diff --git a/nebula_app/src/gpui_shell/file_editor/reader_presentation.rs b/nebula_app/src/gpui_shell/file_editor/reader_presentation.rs index cadf239..ae4b1a6 100644 --- a/nebula_app/src/gpui_shell/file_editor/reader_presentation.rs +++ b/nebula_app/src/gpui_shell/file_editor/reader_presentation.rs @@ -42,3 +42,12 @@ pub(super) fn heading_size(level: Option) -> f32 { _ => 1.0, } } + +pub(super) fn heading_weight(level: Option) -> gpui::FontWeight { + match level { + Some(1) => gpui::FontWeight::BOLD, + Some(2..=5) => gpui::FontWeight::SEMIBOLD, + Some(6) => gpui::FontWeight::MEDIUM, + _ => gpui::FontWeight::NORMAL, + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs b/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs index 317bfd6..97c0c6e 100644 --- a/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs +++ b/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs @@ -31,7 +31,9 @@ impl TextFileView { .flex_shrink_0() .cursor_text() .text_size(px(reader_presentation::heading_size(spec.heading))) - .when(spec.heading.is_some(), |frame| frame.font_semibold()) + .when(spec.heading.is_some(), |frame| { + frame.font_weight(reader_presentation::heading_weight(spec.heading)) + }) .when(run.marks.bold, |frame| frame.font_bold()) .when(run.marks.italic, |frame| frame.italic()) .when(run.marks.link, |frame| frame.text_color(cx.theme().link)) @@ -50,6 +52,9 @@ impl TextFileView { } else { cx.theme().font_family.clone() }; + if spec.heading.is_some() { + style.font_weight = reader_presentation::heading_weight(spec.heading); + } if run.marks.bold { style.font_weight = gpui::FontWeight::BOLD; } diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs index c53aa66..9b04c94 100644 --- a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs +++ b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs @@ -77,6 +77,13 @@ pub(super) fn paint_pane_dividers( } } +fn title_bar_frame() -> gpui::Div { + div().relative().flex_shrink_0().on_mouse_down(MouseButton::Left, |_, _, cx| { + // Window dragging owns this press; it must not anchor the document selection. + gpui_component::global_state::GlobalState::suppress_text_selection(cx); + }) +} + impl NebulaWorkspace { pub(super) fn render_window_title_bar( &self, @@ -118,10 +125,53 @@ impl NebulaWorkspace { bar }; - div() - .relative() - .flex_shrink_0() + title_bar_frame() .when(!settings_active, |title| title.child(self.titlebar_background.element())) .child(bar) } } + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests { + use super::*; + use gpui::{Modifiers, TestAppContext, point}; + use gpui_component::{ + Root, WindowExt, + text::{TextView, TextViewState}, + }; + + struct DocumentWindow(Entity); + + impl Render for DocumentWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + v_flex().size_full().child(title_bar_frame().child(TitleBar::new().h(px(48.0)))).child( + div() + .debug_selector(|| "titlebar-selection-document".to_owned()) + .child(TextView::new(&self.0).selectable(true)), + ) + } + } + + #[gpui::test] + fn title_bar_drag_does_not_anchor_document_selection(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + let (_, cx) = cx.add_window_view(|window, cx| { + let text = cx.new(|cx| TextViewState::markdown("Document text remains selectable", cx)); + let view = cx.new(|_| DocumentWindow(text)); + Root::new(view, window, cx) + }); + let text = cx.debug_bounds("titlebar-selection-document").unwrap(); + cx.simulate_mouse_down(point(px(140.0), px(24.0)), MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move(text.center(), Some(MouseButton::Left), Modifiers::default()); + cx.simulate_mouse_move(text.center(), None, Modifiers::default()); + assert!(cx.update(|window, cx| window.selected_text(cx).is_empty())); + + // The same reader must still support ordinary text dragging afterwards. + let start = point(text.left() + px(1.0), text.top() + px(10.0)); + let end = point(start.x + px(80.0), start.y); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + assert!(!cx.update(|window, cx| window.selected_text(cx).is_empty())); + } +}