Skip to main content

editor/editor/
image.rs

1//! Where a picture comes from.
2//!
3//! Four ways in, and they differ only in what they have to go on. A pasted URL
4//! and the slash menu already name a place to fetch from. A dropped file names
5//! one on disk. A pasted screenshot names nothing at all — it is bytes, and
6//! bytes have to be put somewhere before a document can point at them, which is
7//! the app's decision and not this library's. Hence [`crate::AppExt::set_image_store`],
8//! installed once at boot like `markdown::AppExt::set_links`.
9
10use std::path::{Path, PathBuf};
11
12use gpui::{
13    AnyElement, App, Axis, Context, CursorStyle, Entity, ExternalPaths, Focusable as _, Global,
14    MouseButton, Point, Window, div, img, prelude::*, px,
15};
16use markdown::{Block, BlockKind, Cursor, Part, Selection, Text};
17use theme::Theme;
18use ui::{
19    input::TextField,
20    popover,
21    widgets::{Layout as _, SplitStyle},
22};
23
24use crate::{
25    anchor::Delta,
26    editor::{
27        Editor, MIN_IMAGE_WIDTH,
28        keys::{CancelUrl, ConfirmUrl},
29    },
30    history::EditKind,
31};
32
33/// The key context the URL prompt claims, so `enter` fills the picture in
34/// rather than splitting the document behind it.
35pub const PROMPT_CONTEXT: &str = "BezelImagePrompt";
36
37/// Wide enough for a URL to be read back before it is confirmed.
38const PROMPT_WIDTH: f32 = 280.0;
39
40/// A picture with no address of its own, and what the app is asked about.
41pub enum Source<'a> {
42    /// Bytes off the clipboard — a screenshot, which has no name anywhere.
43    Bytes(&'a gpui::Image),
44    /// A file dragged in from outside, which has one until it is moved.
45    File(&'a Path),
46}
47
48/// Who owns the pictures a document points at.
49///
50/// Plain `fn` fields rather than a trait: a store needs *state* — which
51/// article is open, where the vault is — and in gpui that state lives in the
52/// app, not in a capture. So the store is handed `&App`, the editor doing the
53/// asking and its base, and looks its answer up the same way everything else
54/// here does.
55/// Nothing to box, and [`Default`] means a field added later is not a break.
56#[derive(Clone, Copy)]
57pub struct ImageStore {
58    /// The URL to write down for `source`, and `None` for one the app will not
59    /// keep — a dropped file then stays where it already is, and a screenshot
60    /// is let go.
61    ///
62    /// `editor` is which document is asking, because an app with two windows
63    /// open has two answers and a bare call has no way to tell them apart. It
64    /// is being updated while this runs, so reading it panics; `base` is its
65    /// [`Editor::base`].
66    pub keep: fn(
67        source: Source,
68        editor: &Entity<Editor>,
69        base: Option<&Path>,
70        cx: &App,
71    ) -> Option<String>,
72    /// Whether a file is a picture at all. Defaults to [`markdown::is_image`],
73    /// which guesses from the extension — an app with its own decoder, or one
74    /// that wants a file the guess rejects, says so here.
75    pub accepts: fn(path: &Path) -> bool,
76}
77
78impl Default for ImageStore {
79    fn default() -> Self {
80        Self {
81            keep: |_, _, _, _| None,
82            accepts: |path| markdown::is_image(&path.to_string_lossy()),
83        }
84    }
85}
86
87struct Installed(ImageStore);
88
89impl Global for Installed {}
90
91/// `cx.set_image_store(my_store)` — call once at boot. Without it a
92/// screenshot cannot be pasted at all: there is nowhere to put the bytes, and
93/// a document holds a URL.
94pub(crate) fn set_image_store(cx: &mut App, store: ImageStore) {
95    cx.set_global(Installed(store));
96}
97
98/// The installed store, or the one that keeps nothing — which is what a build
99/// that installed none behaves as.
100pub(crate) fn store(cx: &App) -> ImageStore {
101    cx.try_global::<Installed>().map_or_else(
102        ImageStore::default,
103        // Copied out before the call: a store reads its own globals off the
104        // same `cx` this borrows.
105        |installed| installed.0,
106    )
107}
108
109/// What to write down for the pictures among `paths`. The store says where
110/// each one belongs, and a picture it does not want paints from where it
111/// already is.
112fn image_urls(
113    cx: &App,
114    paths: &[PathBuf],
115    editor: &Entity<Editor>,
116    base: Option<&Path>,
117) -> Vec<String> {
118    let store = store(cx);
119    paths
120        .iter()
121        .filter(|path| (store.accepts)(path))
122        .map(|path| {
123            (store.keep)(Source::File(path), editor, base, cx)
124                .unwrap_or_else(|| path.to_string_lossy().into_owned())
125        })
126        .collect()
127}
128
129/// An open prompt: the image it will fill, the field being typed into, and
130/// where the card hangs.
131pub(crate) struct Prompt {
132    block: usize,
133    field: Entity<TextField>,
134    at: Point<gpui::Pixels>,
135}
136
137impl Editor {
138    /// Ask for the URL of the image at `ix`, and hand it the keys.
139    pub(super) fn prompt_for_url(
140        &mut self,
141        ix: usize,
142        window: &mut Window,
143        cx: &mut Context<Self>,
144    ) {
145        let Some(bounds) = self.layouts.block_bounds(ix) else {
146            return;
147        };
148        // The context goes on the *field*: gpui resolves a chord to the binding
149        // matching deepest in the focus path, and nothing is deeper than the
150        // focused field — a container around it cannot win `enter`.
151        let field = cx.new(|cx| {
152            TextField::new(cx)
153                .with_placeholder("Paste an image URL")
154                .with_key_context(PROMPT_CONTEXT)
155                .with_frame(false)
156        });
157        window.focus(&field.focus_handle(cx), cx);
158        self.slash = None;
159        self.url_prompt = Some(Prompt {
160            block: ix,
161            field,
162            at: gpui::point(
163                bounds.origin.x - self.origin.x,
164                bounds.origin.y - self.origin.y + bounds.size.height,
165            ),
166        });
167        cx.notify();
168    }
169
170    /// Take what was typed. An image already carries its caption, and
171    /// [`markdown::Doc::set_kind`] carries it across the turn.
172    pub(super) fn confirm_url(
173        &mut self,
174        _: &ConfirmUrl,
175        window: &mut Window,
176        cx: &mut Context<Self>,
177    ) {
178        let Some(prompt) = self.url_prompt.take() else {
179            return;
180        };
181        let url = prompt.field.read(cx).content().trim().to_string();
182        self.focus_handle.clone().focus(window, cx);
183        if url.is_empty() {
184            return cx.notify();
185        }
186        self.edit(EditKind::Structure, cx, |this| {
187            this.doc.edit().set_kind(
188                prompt.block,
189                BlockKind::Image {
190                    url,
191                    alt: Text::default(),
192                    width: None,
193                },
194            );
195            this.selection =
196                Selection::at(Cursor::new(prompt.block, Part::Caption, 0).clamp(&this.doc));
197            vec![]
198        });
199    }
200
201    pub(super) fn cancel_url(
202        &mut self,
203        _: &CancelUrl,
204        window: &mut Window,
205        cx: &mut Context<Self>,
206    ) {
207        if self.url_prompt.take().is_some() {
208            self.focus_handle.clone().focus(window, cx);
209            cx.notify();
210        }
211    }
212
213    /// Files dragged in from outside. The store says where each one belongs,
214    /// and a picture it does not want paints from where it already is.
215    pub(super) fn drop_paths(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) {
216        // A picture is a block, and the source has none to give it to.
217        if !self.blocks() {
218            return;
219        }
220        let urls = image_urls(cx, paths.paths(), &cx.entity(), self.base.as_deref());
221        let ix = self.dropping.take().unwrap_or(self.cursor().block);
222        self.place_images(ix, urls, cx);
223        cx.notify();
224    }
225
226    /// Files copied in a file manager, which is a drop by another route — the
227    /// clipboard holds the same paths. `false` when none of them named a
228    /// picture, leaving the paste to the path text the platform put on
229    /// alongside them.
230    pub(super) fn paste_paths(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) -> bool {
231        if !self.blocks() {
232            return false;
233        }
234        let urls = image_urls(cx, paths.paths(), &cx.entity(), self.base.as_deref());
235        if urls.is_empty() {
236            return false;
237        }
238        self.place_images(self.cursor().block, urls, cx);
239        true
240    }
241
242    /// Bytes off the clipboard. `false` when no store is installed, which
243    /// leaves the paste to whatever else the clipboard was carrying.
244    pub(super) fn paste_image(&mut self, image: &gpui::Image, cx: &mut Context<Self>) -> bool {
245        let editor = cx.entity();
246        let Some(url) = (store(cx).keep)(Source::Bytes(image), &editor, self.base.as_deref(), cx)
247        else {
248            return false;
249        };
250        self.place_images(self.cursor().block, vec![url], cx);
251        true
252    }
253
254    /// Put pictures into the document after block `ix` — or in its place, when
255    /// that block is the empty paragraph a caret was left sitting in.
256    fn place_images(&mut self, ix: usize, urls: Vec<String>, cx: &mut Context<Self>) {
257        if urls.is_empty() {
258            return;
259        }
260        self.edit(EditKind::Structure, cx, |this| {
261            let here = this.doc.blocks.get(ix);
262            let indent = here.map_or(0, |block| block.indent);
263            let empty = here.is_some_and(
264                |block| matches!(&block.kind, BlockKind::Paragraph(text) if text.is_empty()),
265            );
266            let mut deltas = Vec::new();
267            let first = if empty {
268                this.doc.edit().blocks.remove(ix);
269                deltas.push(Delta::Moved {
270                    at: ix..ix + 1,
271                    to: None,
272                });
273                ix
274            } else {
275                (ix + 1).min(this.doc.blocks.len())
276            };
277            let last = first + urls.len() - 1;
278            for (offset, url) in urls.into_iter().enumerate() {
279                this.doc.edit().blocks.insert(
280                    first + offset,
281                    Block::at(
282                        BlockKind::Image {
283                            url,
284                            alt: Text::default(),
285                            width: None,
286                        },
287                        indent,
288                    ),
289                );
290            }
291            this.doc.edit().repair();
292            this.selection = Selection::at(Cursor::new(last, Part::Caption, 0).clamp(&this.doc));
293            deltas.push(Delta::Opened {
294                at: first,
295                count: last + 1 - first,
296            });
297            deltas
298        });
299    }
300
301    /// The click target over an image with no URL yet — the row that says so
302    /// is painted by the renderer, and this is what answers a press on it.
303    ///
304    /// The one under the pointer, else the one the caret is in, which is
305    /// [`Editor::language_chip`]'s rule and for the same reason: one element
306    /// placed from the recorded frames, rather than one per block.
307    pub(super) fn image_target(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
308        if !self.blocks() {
309            return None;
310        }
311        let blank = |ix: &usize| {
312            matches!(
313                self.doc.blocks.get(*ix).map(|block| &block.kind),
314                Some(BlockKind::Image { url, .. }) if url.is_empty()
315            )
316        };
317        let ix = [self.hovered, Some(self.cursor().block)]
318            .into_iter()
319            .flatten()
320            .find(blank)?;
321        let bounds = self.layouts.block_bounds(ix)?;
322        Some(
323            div()
324                .id("image-target")
325                .absolute()
326                .left(bounds.origin.x - self.origin.x)
327                .top(bounds.origin.y - self.origin.y)
328                .w(bounds.size.width)
329                .h(bounds.size.height)
330                .cursor(CursorStyle::PointingHand)
331                .on_mouse_down(
332                    MouseButton::Left,
333                    cx.listener(move |this, _: &gpui::MouseDownEvent, window, cx| {
334                        // The editor's own press listener runs after this one
335                        // and would take the focus straight back.
336                        this.press_claimed = true;
337                        this.prompt_for_url(ix, window, cx);
338                    }),
339                )
340                .into_any_element(),
341        )
342    }
343
344    /// How wide the picture at `ix` is allowed to be: from its own left edge
345    /// to the editor's right one, so an indented image gets the narrower
346    /// column it actually sits in.
347    ///
348    /// Never below [`MIN_IMAGE_WIDTH`]: a window narrower than the floor would
349    /// otherwise put the ceiling under it, which clamps the wrong way round
350    /// and panics rather than giving back a small picture.
351    pub(super) fn column_width(&self, ix: usize) -> Option<u32> {
352        let bounds = self.layouts.picture_bounds(ix)?;
353        let column = self.origin.x + self.width - bounds.origin.x;
354        Some(column.max(px(MIN_IMAGE_WIDTH)).as_f32().round() as u32)
355    }
356
357    /// The width a drag reaching `x` asks for, bounded by the column.
358    pub(super) fn dragged_width(&self, ix: usize, x: gpui::Pixels) -> Option<u32> {
359        let bounds = self.layouts.picture_bounds(ix)?;
360        let asked = (x - bounds.origin.x)
361            .max(px(MIN_IMAGE_WIDTH))
362            .as_f32()
363            .round() as u32;
364        Some(asked.min(self.column_width(ix)?))
365    }
366
367    /// Where the picture at `ix` sits at `width`, in this editor's own
368    /// coordinates.
369    ///
370    /// The width is taken from `width` rather than from the box that painted,
371    /// which is a frame behind: a handle placed from that box would sit at the
372    /// picture's previous edge until something unrelated asked for another
373    /// frame. The origin and the height are read back, because neither is
374    /// something this can work out and both are right for the picture as it
375    /// stands.
376    pub(super) fn picture_box(
377        &self,
378        ix: usize,
379        width: Option<u32>,
380    ) -> Option<gpui::Bounds<gpui::Pixels>> {
381        let painted = self.layouts.picture_bounds(ix)?;
382        // Zero until the picture has decoded — nothing to grab yet.
383        if painted.size.width <= px(0.0) {
384            return None;
385        }
386        let column = self.column_width(ix)?;
387        let width = width.map_or(painted.size.width, |width| px(width.min(column) as f32));
388        Some(gpui::Bounds::new(
389            gpui::point(
390                painted.origin.x - self.origin.x,
391                painted.origin.y - self.origin.y,
392            ),
393            gpui::size(width, painted.size.height),
394        ))
395    }
396
397    /// End a resize, writing the width down. `false` when none was running,
398    /// which is what leaves the release to whatever else claims it.
399    pub(super) fn drop_resize(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
400        let Some((ix, live)) = self.resizing.take() else {
401            return false;
402        };
403        // Dragged back out to the column: forget the width rather than pin it
404        // a pixel short, so letting go all the way round-trips as plain
405        // `![alt](url)` again.
406        let width = match (live, self.column_width(ix)) {
407            (Some(live), Some(full)) if live + 2 >= full => None,
408            _ => live,
409        };
410        let current = self.doc.blocks.get(ix).and_then(|block| match &block.kind {
411            BlockKind::Image { width, .. } => Some(*width),
412            _ => None,
413        });
414        if current != Some(width) {
415            self.edit(EditKind::Structure, cx, |this| {
416                if let Some(BlockKind::Image { width: at, .. }) = this
417                    .doc
418                    .edit()
419                    .blocks
420                    .get_mut(ix)
421                    .map(|block| &mut block.kind)
422                {
423                    *at = width;
424                }
425                vec![]
426            });
427            // Made where the handle was, not at the caret — see `drop_height`.
428            self.reveal = None;
429            // Handed back its natural width, which only the paint that
430            // measures it knows: the next frame still places the handle from
431            // the width being left behind, and this asks for the one after,
432            // which can see the picture it belongs to.
433            if width.is_none() {
434                window.refresh();
435            }
436        } else {
437            // The stand-in picture is painted from `resizing` and this frame
438            // no longer has one, which an edit would have said for us.
439            cx.notify();
440        }
441        true
442    }
443
444    /// The grab strip along a picture's right edge — the one under the
445    /// pointer, else the one being resized (which can end up outside the
446    /// image once a drag overshoots its bounds).
447    ///
448    /// Painted with [`ui::widgets::Layout::split_handle`] rather than a
449    /// bespoke bar: a pane divider and an image's edge are the same shape,
450    /// a hairline in a wider grab strip, so the resized picture matches the
451    /// rest of the app's resize affordances instead of inventing a second one.
452    pub(super) fn resize_handle(
453        &self,
454        theme: &Theme,
455        cx: &mut Context<Self>,
456    ) -> Option<AnyElement> {
457        let has_url = |ix: &usize| {
458            matches!(
459                self.doc.blocks.get(*ix).map(|block| &block.kind),
460                Some(BlockKind::Image { url, .. }) if !url.is_empty()
461            )
462        };
463        let ix = [self.resizing.map(|(ix, _)| ix), self.hovered]
464            .into_iter()
465            .flatten()
466            .find(has_url)?;
467        let dragging = self.resizing.is_some_and(|(resizing, _)| resizing == ix);
468        // The document's own value, so a press that never moves releases into
469        // the width it started from and writes nothing.
470        let current = match self.doc.blocks.get(ix).map(|block| &block.kind) {
471            Some(BlockKind::Image { width, .. }) => *width,
472            _ => None,
473        };
474        let picture = self.picture_box(ix, current)?;
475        Some(
476            theme
477                .split_handle(Axis::Horizontal, SplitStyle::Line { dragging })
478                .id("image-resize-handle")
479                .absolute()
480                .left(picture.origin.x + picture.size.width - px(4.5))
481                .top(picture.origin.y)
482                .h(picture.size.height)
483                .on_mouse_down(
484                    MouseButton::Left,
485                    cx.listener(move |this, _: &gpui::MouseDownEvent, _, cx| {
486                        this.press_claimed = true;
487                        this.resizing = Some((ix, current));
488                        cx.notify();
489                    }),
490                )
491                .into_any_element(),
492        )
493    }
494
495    /// The picture at its live width while a resize is in flight — painted a
496    /// second time, directly over the real one, because that one is
497    /// [`markdown::Doc`] data and the document holds nothing until the handle
498    /// is released.
499    pub(super) fn resize_preview(&self) -> Option<AnyElement> {
500        // Nothing dragged yet, so nothing to stand in for: the picture already
501        // painted is the picture this width means.
502        let (ix, live) = match self.resizing? {
503            (ix, Some(live)) => (ix, live),
504            _ => return None,
505        };
506        let Some(BlockKind::Image { url, .. }) = self.doc.blocks.get(ix).map(|block| &block.kind)
507        else {
508            return None;
509        };
510        let box_ = self.picture_box(ix, Some(live))?;
511        let picture = img(markdown::image_source(url, self.base.as_deref()));
512        Some(
513            div()
514                .absolute()
515                .left(box_.origin.x)
516                .top(box_.origin.y)
517                // No height: the picture derives its own from the width it is
518                // given, which is right whatever it is a picture of. Computing
519                // one here would need an aspect ratio, and the only box to
520                // read one off spans the column rather than the picture until
521                // a width has been written down.
522                .w(box_.size.width)
523                .overflow_hidden()
524                .child(picture.w(box_.size.width))
525                .into_any_element(),
526        )
527    }
528
529    /// The prompt itself, under the picture it belongs to.
530    pub(super) fn url_prompt(&self, theme: &Theme, cx: &mut Context<Self>) -> Option<AnyElement> {
531        let prompt = self.url_prompt.as_ref()?;
532        Some(popover::menu_at(
533            "image-url",
534            prompt.at,
535            popover::popover_card(theme)
536                .w(px(PROMPT_WIDTH))
537                // Without this the editor's own press listener runs next and
538                // takes the focus back off the field being clicked into.
539                .on_mouse_down(
540                    MouseButton::Left,
541                    cx.listener(|this, _: &gpui::MouseDownEvent, _, _| this.press_claimed = true),
542                )
543                .on_mouse_down_out(
544                    cx.listener(|this, _, window, cx| this.cancel_url(&CancelUrl, window, cx)),
545                )
546                // The card is this field's frame; a box on the field would be a
547                // second one. Padded like a menu row so the text lands where a
548                // row's would.
549                .child(
550                    div()
551                        .px(px(8.0))
552                        .py(px(6.0))
553                        .child(prompt.field.clone().into_any_element()),
554                )
555                .into_any_element(),
556            None,
557        ))
558    }
559}
560
561impl Editor {
562    /// The picture under `position`, as its right-click menu's target.
563    pub(super) fn image_target_at(
564        &self,
565        position: Point<gpui::Pixels>,
566    ) -> Option<markdown::PictureMenu> {
567        let ix = self.layouts.block_at(position)?;
568        if !self.layouts.picture_bounds(ix)?.contains(&position) {
569            return None;
570        }
571        let BlockKind::Image { url, .. } = &self.doc.blocks.get(ix)?.kind else {
572            return None;
573        };
574        Some(markdown::PictureMenu::new(url, self.base()))
575    }
576}