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 // `` 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}