1use 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
33pub const PROMPT_CONTEXT: &str = "BezelImagePrompt";
36
37const PROMPT_WIDTH: f32 = 280.0;
39
40pub enum Source<'a> {
42 Bytes(&'a gpui::Image),
44 File(&'a Path),
46}
47
48#[derive(Clone, Copy)]
57pub struct ImageStore {
58 pub keep: fn(
67 source: Source,
68 editor: &Entity<Editor>,
69 base: Option<&Path>,
70 cx: &App,
71 ) -> Option<String>,
72 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
91pub(crate) fn set_image_store(cx: &mut App, store: ImageStore) {
95 cx.set_global(Installed(store));
96}
97
98pub(crate) fn store(cx: &App) -> ImageStore {
101 cx.try_global::<Installed>().map_or_else(
102 ImageStore::default,
103 |installed| installed.0,
106 )
107}
108
109fn 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
129pub(crate) struct Prompt {
132 block: usize,
133 field: Entity<TextField>,
134 at: Point<gpui::Pixels>,
135}
136
137impl Editor {
138 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 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 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 pub(super) fn drop_paths(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) {
216 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 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 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 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 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 this.press_claimed = true;
337 this.prompt_for_url(ix, window, cx);
338 }),
339 )
340 .into_any_element(),
341 )
342 }
343
344 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 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 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 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 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 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 self.reveal = false;
425 if width.is_none() {
430 window.refresh();
431 }
432 } else {
433 cx.notify();
436 }
437 true
438 }
439
440 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 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 pub(super) fn resize_preview(&self) -> Option<AnyElement> {
496 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 .w(box_.size.width)
519 .overflow_hidden()
520 .child(picture.w(box_.size.width))
521 .into_any_element(),
522 )
523 }
524
525 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 .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 .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
557pub(crate) struct ImageTarget {
560 url: String,
561 file: Option<PathBuf>,
562}
563
564#[derive(Clone, Copy)]
566enum ImageAction {
567 Open,
568 Reveal,
569 CopyImage,
570 CopyAddress,
571}
572
573impl ImageTarget {
574 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 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 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 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}