Stabilize Markdown editing, selection and popup geometry
This commit is contained in:
1 parent
07afade392
commit
f91c88261c
13 files changed
+490
-103
No files matched your search
Generated
+3
-3
@@ -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",
|
||||
|
||||
+5
-2
@@ -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" }
|
||||
+98
@@ -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.
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<String>();
|
||||
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));
|
||||
}
|
||||
@@ -19,11 +19,18 @@ pub(super) struct LiveEdit {
|
||||
pub(super) last_selection: Range<usize>,
|
||||
changed: bool,
|
||||
heading: Option<u8>,
|
||||
pending_click: Option<PendingClick>,
|
||||
pub(super) decorations: TextDecorationCollection,
|
||||
_subscription: Subscription,
|
||||
_observation: Subscription,
|
||||
}
|
||||
|
||||
struct PendingClick {
|
||||
position: Point<Pixels>,
|
||||
text: SharedString,
|
||||
selection: Range<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn decorations(projection: &Projection, cx: &App) -> Vec<TextDecoration> {
|
||||
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<Pixels>| {
|
||||
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<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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:#?}"
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,6 @@ pub struct TextFileView {
|
||||
preview_extensions: gpui_component::text::MarkdownExtensions,
|
||||
preview_images: Entity<image_cache::DocumentImageCache>,
|
||||
scroll: ListState,
|
||||
preview_bounds: Rc<RefCell<Bounds<Pixels>>>,
|
||||
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(),
|
||||
|
||||
@@ -210,6 +210,7 @@ fn block_frame(index: usize, heading: Option<u8>) -> gpui::Stateful<gpui::Div> {
|
||||
.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(),
|
||||
|
||||
@@ -42,3 +42,12 @@ pub(super) fn heading_size(level: Option<u8>) -> f32 {
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn heading_weight(level: Option<u8>) -> gpui::FontWeight {
|
||||
match level {
|
||||
Some(1) => gpui::FontWeight::BOLD,
|
||||
Some(2..=5) => gpui::FontWeight::SEMIBOLD,
|
||||
Some(6) => gpui::FontWeight::MEDIUM,
|
||||
_ => gpui::FontWeight::NORMAL,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<TextViewState>);
|
||||
|
||||
impl Render for DocumentWindow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user