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