Stabilize Markdown editing, selection and popup geometry

This commit is contained in:
kuddev committed 2026-09-21 10:25:00 +08:00
1 parent 07afade392
commit f91c88261c
13 files changed
+490 -103

No files matched your search

Generated
+3 -3
View File
@@ -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
View File
@@ -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" }
@@ -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()));
}
}