Skip to main content

markdown_editor/
component.rs

1//! Themed, content-sized Markdown editor. Base retains all editing behavior.
2pub use crate::{
3    BlockEditorEvent, BlockEditorState, CapturedRange, CapturedRangeError, ClipboardPolicy,
4    ImagePolicy, PasteRequest, SourceSnapshot, SubmitPolicy,
5};
6use gpui::prelude::FluentBuilder as _;
7use gpui::{
8    App, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
9    StatefulInteractiveElement, Styled, Window, div, rems,
10};
11use gpui_kit::component::ActiveTheme;
12
13// Medium input-family proportions (10px/8px at the default 16px rem),
14// expressed on the same relative axis as the editor's content rows.
15const INPUT_PADDING_X: f32 = 0.625;
16const INPUT_PADDING_Y: f32 = 0.5;
17const ROW_HEIGHT: f32 = 1.5;
18
19#[derive(IntoElement)]
20pub struct BlockEditor {
21    state: Entity<BlockEditorState>,
22    min_rows: usize,
23    max_rows: usize,
24}
25impl BlockEditor {
26    pub fn new(state: &Entity<BlockEditorState>) -> Self {
27        Self {
28            state: state.clone(),
29            min_rows: 2,
30            max_rows: 16,
31        }
32    }
33    /// Content-sized viewport clamped to content rows plus internal insets. The
34    /// editor's retained handle owns scrolling and caret reveal; no outer
35    /// scrolling node or application height mirror is needed.
36    pub fn auto_grow(mut self, min: usize, max: usize) -> Self {
37        self.min_rows = min.max(1);
38        self.max_rows = max.max(self.min_rows);
39        self
40    }
41}
42impl RenderOnce for BlockEditor {
43    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
44        let scroll = self.state.read(cx).scroll_handle();
45        let min_height = rems(self.min_rows as f32 * ROW_HEIGHT + INPUT_PADDING_Y * 2.);
46        let max_height = rems(self.max_rows as f32 * ROW_HEIGHT + INPUT_PADDING_Y * 2.);
47        div()
48            .id(("block-editor-viewport", self.state.entity_id()))
49            .w_full()
50            .min_w_0()
51            .min_h(min_height)
52            .max_h(max_height)
53            .overflow_y_scroll()
54            .when_some(scroll, |this, scroll| this.track_scroll(&scroll))
55            .text_color(cx.theme().foreground)
56            // Padding belongs to the native root, inside the sole scroll owner.
57            .child(
58                crate::BlockEditor::new(&self.state)
59                    .min_h(min_height)
60                    .px(rems(INPUT_PADDING_X))
61                    .py(rems(INPUT_PADDING_Y)),
62            )
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::{BlockPosition, BlockSelection};
70    use gpui::{
71        AppContext as _, Bounds, Context, EntityInputHandler as _, Focusable as _, MouseButton,
72        Pixels, Render, TestAppContext, VisualTestContext, point, px,
73    };
74
75    struct Harness(Entity<BlockEditorState>, f32);
76
77    impl Render for Harness {
78        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
79            div()
80                .w(rems(self.1))
81                .text_size(rems(1.))
82                .child(BlockEditor::new(&self.0).auto_grow(2, 4))
83        }
84    }
85
86    fn draw(cx: &mut VisualTestContext) {
87        cx.run_until_parked();
88        cx.update(|window, cx| window.draw(cx).clear(cx));
89    }
90
91    fn caret(editor: &Entity<BlockEditorState>, cx: &mut VisualTestContext) -> Bounds<Pixels> {
92        cx.update(|window, cx| {
93            editor.update(cx, |state, cx| {
94                let offset = state.model().caret().unwrap().offset;
95                state
96                    .bounds_for_range(offset..offset, Bounds::default(), window, cx)
97                    .expect("the selected leaf is laid out")
98            })
99        })
100    }
101
102    #[gpui::test]
103    fn inset_pointer_edit_and_reveal_survive_zoom_and_rewrap(cx: &mut TestAppContext) {
104        cx.update(|cx| {
105            cx.set_http_client(gpui::http_client::FakeHttpClient::create(|_| async {
106                Err(anyhow::anyhow!(
107                    "outbound requests forbidden in editor test"
108                ))
109            }));
110            crate::init_for_tests(cx);
111        });
112        let (root, cx) = cx.add_window_view(|window, cx| {
113            Harness(
114                cx.new(|cx| BlockEditorState::new(window, cx).with_source("alpha beta")),
115                20.,
116            )
117        });
118        let editor = root.read_with(cx, |root, _| root.0.clone());
119        draw(cx);
120        let scroll = editor.read_with(cx, |state, _| state.scroll_handle().unwrap());
121        let inset = cx.update(|window, _| {
122            let view = scroll.bounds();
123            point(
124                view.left() + window.rem_size() / 4.,
125                view.top() + window.rem_size() / 4.,
126            )
127        });
128        cx.simulate_click(inset, Default::default());
129        cx.simulate_input("X");
130        draw(cx);
131        assert_eq!(
132            editor.read_with(cx, |state, _| state.source()),
133            "Xalpha beta"
134        );
135        assert!(cx.update(|window, cx| editor.read(cx).focus_handle(cx).is_focused(window)));
136
137        // Selection is driven by the actual native text geometry, not a
138        // separately translated source/cursor coordinate system.
139        let start = caret(&editor, cx).center();
140        let end = cx.update(|window, cx| {
141            editor.update(cx, |state, cx| {
142                state
143                    .bounds_for_range(6..6, Bounds::default(), window, cx)
144                    .unwrap()
145                    .center()
146            })
147        });
148        cx.simulate_mouse_down(start, MouseButton::Left, Default::default());
149        cx.simulate_mouse_move(end, Some(MouseButton::Left), Default::default());
150        cx.simulate_mouse_up(end, MouseButton::Left, Default::default());
151        cx.simulate_input("word");
152        draw(cx);
153        assert_eq!(
154            editor.read_with(cx, |state, _| state.source()),
155            "Xword beta"
156        );
157
158        let trailing_inset = cx.update(|window, _| {
159            let view = scroll.bounds();
160            point(
161                view.right() - window.rem_size() / 4.,
162                view.bottom() - window.rem_size() / 4.,
163            )
164        });
165        cx.simulate_click(trailing_inset, Default::default());
166        cx.simulate_input("Y");
167        draw(cx);
168        assert_eq!(
169            editor.read_with(cx, |state, _| state.source()),
170            "Xword betaY"
171        );
172
173        let source = (0..80)
174            .map(|_| "one two three four five six seven eight nine ten")
175            .collect::<Vec<_>>()
176            .join("\n\n");
177        editor.update(cx, |state, cx| state.set_source(&source, cx).unwrap());
178        draw(cx);
179        editor.update(cx, |state, cx| {
180            let last = state.model().document().leaves().last().unwrap();
181            state
182                .set_selection(
183                    BlockSelection::caret(BlockPosition::new(
184                        last.id(),
185                        last.text().unwrap().len(),
186                    )),
187                    cx,
188                )
189                .unwrap();
190        });
191        draw(cx);
192        assert!(
193            scroll.offset().y < Pixels::ZERO,
194            "the retained owner scrolls to the last leaf"
195        );
196        assert!(scroll.bounds().contains(&caret(&editor, cx).center()));
197
198        // Change both rem and available width without changing the source;
199        // cached off-screen heights must not leave the caret outside its owner.
200        cx.update(|window, _| window.set_rem_size(px(24.)));
201        root.update(cx, |root, cx| {
202            root.1 = 12.;
203            cx.notify();
204        });
205        // Settle the layout invalidation and native reveal follow-up frame.
206        draw(cx);
207        draw(cx);
208        assert!(scroll.bounds().contains(&caret(&editor, cx).center()));
209        editor.update(cx, |state, cx| {
210            let first = state.model().document().leaves().next().unwrap().id();
211            state
212                .set_selection(BlockSelection::caret(BlockPosition::new(first, 0)), cx)
213                .unwrap();
214        });
215        draw(cx);
216        let inset = cx.update(|window, _| {
217            let view = scroll.bounds();
218            point(
219                view.left() + window.rem_size() / 4.,
220                view.top() + window.rem_size() / 4.,
221            )
222        });
223        cx.simulate_click(inset, Default::default());
224        cx.simulate_input("Z");
225        draw(cx);
226        assert_eq!(
227            editor.read_with(cx, |state, _| state.source()),
228            format!("Z{source}")
229        );
230    }
231}