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_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    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.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.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.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.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, .. }) =
417                    this.doc.blocks.get_mut(ix).map(|block| &mut block.kind)
418                {
419                    *at = width;
420                }
421                vec![]
422            });
423            // Made where the handle was, not at the caret — see `drop_height`.
424            self.reveal = None;
425            // Handed back its natural width, which only the paint that
426            // measures it knows: the next frame still places the handle from
427            // the width being left behind, and this asks for the one after,
428            // which can see the picture it belongs to.
429            if width.is_none() {
430                window.refresh();
431            }
432        } else {
433            // The stand-in picture is painted from `resizing` and this frame
434            // no longer has one, which an edit would have said for us.
435            cx.notify();
436        }
437        true
438    }
439
440    /// The grab strip along a picture's right edge — the one under the
441    /// pointer, else the one being resized (which can end up outside the
442    /// image once a drag overshoots its bounds).
443    ///
444    /// Painted with [`ui::widgets::Layout::split_handle`] rather than a
445    /// bespoke bar: a pane divider and an image's edge are the same shape,
446    /// a hairline in a wider grab strip, so the resized picture matches the
447    /// rest of the app's resize affordances instead of inventing a second one.
448    pub(super) fn resize_handle(
449        &self,
450        theme: &Theme,
451        cx: &mut Context<Self>,
452    ) -> Option<AnyElement> {
453        let has_url = |ix: &usize| {
454            matches!(
455                self.doc.blocks.get(*ix).map(|block| &block.kind),
456                Some(BlockKind::Image { url, .. }) if !url.is_empty()
457            )
458        };
459        let ix = [self.resizing.map(|(ix, _)| ix), self.hovered]
460            .into_iter()
461            .flatten()
462            .find(has_url)?;
463        let dragging = self.resizing.is_some_and(|(resizing, _)| resizing == ix);
464        // The document's own value, so a press that never moves releases into
465        // the width it started from and writes nothing.
466        let current = match self.doc.blocks.get(ix).map(|block| &block.kind) {
467            Some(BlockKind::Image { width, .. }) => *width,
468            _ => None,
469        };
470        let picture = self.picture_box(ix, current)?;
471        Some(
472            theme
473                .split_handle(Axis::Horizontal, SplitStyle::Line { dragging })
474                .id("image-resize-handle")
475                .absolute()
476                .left(picture.origin.x + picture.size.width - px(4.5))
477                .top(picture.origin.y)
478                .h(picture.size.height)
479                .on_mouse_down(
480                    MouseButton::Left,
481                    cx.listener(move |this, _: &gpui::MouseDownEvent, _, cx| {
482                        this.press_claimed = true;
483                        this.resizing = Some((ix, current));
484                        cx.notify();
485                    }),
486                )
487                .into_any_element(),
488        )
489    }
490
491    /// The picture at its live width while a resize is in flight — painted a
492    /// second time, directly over the real one, because that one is
493    /// [`markdown::Doc`] data and the document holds nothing until the handle
494    /// is released.
495    pub(super) fn resize_preview(&self) -> Option<AnyElement> {
496        // Nothing dragged yet, so nothing to stand in for: the picture already
497        // painted is the picture this width means.
498        let (ix, live) = match self.resizing? {
499            (ix, Some(live)) => (ix, live),
500            _ => return None,
501        };
502        let Some(BlockKind::Image { url, .. }) = self.doc.blocks.get(ix).map(|block| &block.kind)
503        else {
504            return None;
505        };
506        let box_ = self.picture_box(ix, Some(live))?;
507        let picture = img(markdown::image_source(url, self.base.as_deref()));
508        Some(
509            div()
510                .absolute()
511                .left(box_.origin.x)
512                .top(box_.origin.y)
513                // No height: the picture derives its own from the width it is
514                // given, which is right whatever it is a picture of. Computing
515                // one here would need an aspect ratio, and the only box to
516                // read one off spans the column rather than the picture until
517                // a width has been written down.
518                .w(box_.size.width)
519                .overflow_hidden()
520                .child(picture.w(box_.size.width))
521                .into_any_element(),
522        )
523    }
524
525    /// The prompt itself, under the picture it belongs to.
526    pub(super) fn url_prompt(&self, theme: &Theme, cx: &mut Context<Self>) -> Option<AnyElement> {
527        let prompt = self.url_prompt.as_ref()?;
528        Some(popover::menu_at(
529            "image-url",
530            prompt.at,
531            popover::popover_card(theme)
532                .w(px(PROMPT_WIDTH))
533                // Without this the editor's own press listener runs next and
534                // takes the focus back off the field being clicked into.
535                .on_mouse_down(
536                    MouseButton::Left,
537                    cx.listener(|this, _: &gpui::MouseDownEvent, _, _| this.press_claimed = true),
538                )
539                .on_mouse_down_out(
540                    cx.listener(|this, _, window, cx| this.cancel_url(&CancelUrl, window, cx)),
541                )
542                // The card is this field's frame; a box on the field would be a
543                // second one. Padded like a menu row so the text lands where a
544                // row's would.
545                .child(
546                    div()
547                        .px(px(8.0))
548                        .py(px(6.0))
549                        .child(prompt.field.clone().into_any_element()),
550                )
551                .into_any_element(),
552            None,
553        ))
554    }
555}
556
557/// What a picture's right-click menu acts on: its URL as written, and the
558/// file that resolves to when it is one.
559pub(crate) struct ImageTarget {
560    url: String,
561    file: Option<PathBuf>,
562}
563
564/// A row of a picture's right-click menu.
565#[derive(Clone, Copy)]
566enum ImageAction {
567    Open,
568    Reveal,
569    CopyImage,
570    CopyAddress,
571}
572
573impl ImageTarget {
574    /// The rows, `None` for a separator — what [`Self::items`] paints and a
575    /// chosen path indexes.
576    fn actions(&self) -> Vec<Option<ImageAction>> {
577        let mut actions = Vec::new();
578        if self.file.is_some() {
579            actions.extend([Some(ImageAction::Open), Some(ImageAction::Reveal), None]);
580            if self.format().is_some() {
581                actions.push(Some(ImageAction::CopyImage));
582            }
583        }
584        actions.push(Some(ImageAction::CopyAddress));
585        actions
586    }
587
588    pub(crate) fn items(&self) -> Vec<ui::menu::Item> {
589        self.actions()
590            .into_iter()
591            .map(|action| match action {
592                None => ui::menu::Item::Separator,
593                Some(ImageAction::Open) => ui::menu::Item::action("Open"),
594                Some(ImageAction::Reveal) => ui::menu::Item::action(if cfg!(target_os = "macos") {
595                    "Reveal in Finder"
596                } else {
597                    "Show in Folder"
598                }),
599                Some(ImageAction::CopyImage) => ui::menu::Item::action("Copy Image"),
600                Some(ImageAction::CopyAddress) => ui::menu::Item::action("Copy Image Address"),
601            })
602            .collect()
603    }
604
605    /// The file's picture format, read off its extension.
606    fn format(&self) -> Option<gpui::ImageFormat> {
607        let extension = self.file.as_ref()?.extension()?.to_str()?.to_lowercase();
608        gpui::ImageFormat::from_mime_type(&format!("image/{extension}"))
609    }
610
611    /// Run the row at `row`.
612    pub(crate) fn run(&self, row: usize, cx: &mut App) {
613        let Some(Some(action)) = self.actions().get(row).copied() else {
614            return;
615        };
616        match (action, &self.file) {
617            (ImageAction::Open, Some(file)) => cx.open_with_system(file),
618            (ImageAction::Reveal, Some(file)) => cx.reveal_path(file),
619            (ImageAction::CopyImage, Some(file)) => {
620                if let (Some(format), Ok(bytes)) = (self.format(), std::fs::read(file)) {
621                    cx.write_to_clipboard(gpui::ClipboardItem::new_image(
622                        &gpui::Image::from_bytes(format, bytes),
623                    ));
624                }
625            }
626            (ImageAction::CopyAddress, _) => {
627                cx.write_to_clipboard(gpui::ClipboardItem::new_string(self.url.clone()));
628            }
629            _ => {}
630        }
631    }
632}
633
634impl Editor {
635    /// The picture under `position`, as its right-click menu's target.
636    pub(super) fn image_target_at(&self, position: Point<gpui::Pixels>) -> Option<ImageTarget> {
637        let ix = self.layouts.block_at(position)?;
638        if !self.layouts.picture_bounds(ix)?.contains(&position) {
639            return None;
640        }
641        let BlockKind::Image { url, .. } = &self.doc.blocks.get(ix)?.kind else {
642            return None;
643        };
644        let file = (!url.contains("://")).then(|| match self.base() {
645            Some(base) => base.join(url),
646            None => PathBuf::from(url),
647        });
648        Some(ImageTarget {
649            url: url.clone(),
650            file,
651        })
652    }
653}