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