use std::path::Path;
use gpui::{
AnyElement, App, Axis, Context, CursorStyle, Entity, ExternalPaths, Focusable as _, Global,
MouseButton, Point, Window, div, img, prelude::*, px,
};
use markdown::{Block, BlockKind, Cursor, Part, Selection, Text};
use theme::Theme;
use ui::{
input::TextField,
popover,
widgets::{Layout as _, SplitStyle},
};
use crate::{
comment::Delta,
editor::{
Editor, MIN_IMAGE_WIDTH,
keys::{CancelUrl, ConfirmUrl},
},
history::EditKind,
};
pub const PROMPT_CONTEXT: &str = "BezelImagePrompt";
const PROMPT_WIDTH: f32 = 280.0;
pub enum Source<'a> {
Bytes(&'a gpui::Image),
File(&'a Path),
}
pub type ImageStore = fn(Source) -> Option<String>;
struct Installed(ImageStore);
impl Global for Installed {}
pub fn set_image_store(cx: &mut App, store: ImageStore) {
cx.set_global(Installed(store));
}
fn stored(cx: &App, source: Source) -> Option<String> {
(cx.try_global::<Installed>()?.0)(source)
}
pub(crate) struct Prompt {
block: usize,
field: Entity<TextField>,
at: Point<gpui::Pixels>,
}
impl Editor {
pub(super) fn prompt_for_url(
&mut self,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(bounds) = self.layouts.block_bounds(ix) else {
return;
};
let field = cx.new(|cx| {
TextField::new(cx)
.with_placeholder("Paste an image URL")
.with_key_context(PROMPT_CONTEXT)
.with_frame(false)
});
window.focus(&field.focus_handle(cx), cx);
self.slash = None;
self.url_prompt = Some(Prompt {
block: ix,
field,
at: gpui::point(
bounds.origin.x - self.origin.x,
bounds.origin.y - self.origin.y + bounds.size.height,
),
});
cx.notify();
}
pub(super) fn confirm_url(
&mut self,
_: &ConfirmUrl,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(prompt) = self.url_prompt.take() else {
return;
};
let url = prompt.field.read(cx).content().trim().to_string();
self.focus_handle.clone().focus(window, cx);
if url.is_empty() {
return cx.notify();
}
self.edit(EditKind::Structure, cx, |this| {
this.doc.set_kind(
prompt.block,
BlockKind::Image {
url,
alt: Text::default(),
width: None,
},
);
this.selection =
Selection::at(Cursor::new(prompt.block, Part::Caption, 0).clamp(&this.doc));
vec![]
});
}
pub(super) fn cancel_url(
&mut self,
_: &CancelUrl,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.url_prompt.take().is_some() {
self.focus_handle.clone().focus(window, cx);
cx.notify();
}
}
pub(super) fn drop_paths(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) {
let urls: Vec<String> = paths
.paths()
.iter()
.filter(|path| markdown::is_image(&path.to_string_lossy()))
.map(|path| {
stored(cx, Source::File(path))
.unwrap_or_else(|| path.to_string_lossy().into_owned())
})
.collect();
let ix = self.dropping.take().unwrap_or(self.cursor().block);
self.place_images(ix, urls, cx);
cx.notify();
}
pub(super) fn paste_image(&mut self, image: &gpui::Image, cx: &mut Context<Self>) -> bool {
let Some(url) = stored(cx, Source::Bytes(image)) else {
return false;
};
self.place_images(self.cursor().block, vec![url], cx);
true
}
fn place_images(&mut self, ix: usize, urls: Vec<String>, cx: &mut Context<Self>) {
if urls.is_empty() {
return;
}
self.edit(EditKind::Structure, cx, |this| {
let here = this.doc.blocks.get(ix);
let indent = here.map_or(0, |block| block.indent);
let empty = here.is_some_and(
|block| matches!(&block.kind, BlockKind::Paragraph(text) if text.is_empty()),
);
let mut deltas = Vec::new();
let first = if empty {
this.doc.blocks.remove(ix);
deltas.push(Delta::Moved {
at: ix..ix + 1,
to: None,
});
ix
} else {
(ix + 1).min(this.doc.blocks.len())
};
let last = first + urls.len() - 1;
for (offset, url) in urls.into_iter().enumerate() {
this.doc.blocks.insert(
first + offset,
Block::at(
BlockKind::Image {
url,
alt: Text::default(),
width: None,
},
indent,
),
);
}
this.doc.repair();
this.selection = Selection::at(Cursor::new(last, Part::Caption, 0).clamp(&this.doc));
deltas.push(Delta::Opened {
at: first,
count: last + 1 - first,
});
deltas
});
}
pub(super) fn image_target(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let blank = |ix: &usize| {
matches!(
self.doc.blocks.get(*ix).map(|block| &block.kind),
Some(BlockKind::Image { url, .. }) if url.is_empty()
)
};
let ix = [self.hovered, Some(self.cursor().block)]
.into_iter()
.flatten()
.find(blank)?;
let bounds = self.layouts.block_bounds(ix)?;
Some(
div()
.id("image-target")
.absolute()
.left(bounds.origin.x - self.origin.x)
.top(bounds.origin.y - self.origin.y)
.w(bounds.size.width)
.h(bounds.size.height)
.cursor(CursorStyle::PointingHand)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _: &gpui::MouseDownEvent, window, cx| {
this.press_claimed = true;
this.prompt_for_url(ix, window, cx);
}),
)
.into_any_element(),
)
}
pub(super) fn column_width(&self, ix: usize) -> Option<u32> {
let bounds = self.layouts.picture_bounds(ix)?;
let column = self.origin.x + self.width - bounds.origin.x;
Some(column.max(px(MIN_IMAGE_WIDTH)).as_f32().round() as u32)
}
pub(super) fn dragged_width(&self, ix: usize, x: gpui::Pixels) -> Option<u32> {
let bounds = self.layouts.picture_bounds(ix)?;
let asked = (x - bounds.origin.x)
.max(px(MIN_IMAGE_WIDTH))
.as_f32()
.round() as u32;
Some(asked.min(self.column_width(ix)?))
}
pub(super) fn picture_box(
&self,
ix: usize,
width: Option<u32>,
) -> Option<gpui::Bounds<gpui::Pixels>> {
let painted = self.layouts.picture_bounds(ix)?;
if painted.size.width <= px(0.0) {
return None;
}
let column = self.column_width(ix)?;
let width = width.map_or(painted.size.width, |width| px(width.min(column) as f32));
Some(gpui::Bounds::new(
gpui::point(
painted.origin.x - self.origin.x,
painted.origin.y - self.origin.y,
),
gpui::size(width, painted.size.height),
))
}
pub(super) fn drop_resize(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some((ix, live)) = self.resizing.take() else {
return false;
};
let width = match (live, self.column_width(ix)) {
(Some(live), Some(full)) if live + 2 >= full => None,
_ => live,
};
let current = self.doc.blocks.get(ix).and_then(|block| match &block.kind {
BlockKind::Image { width, .. } => Some(*width),
_ => None,
});
if current != Some(width) {
self.edit(EditKind::Structure, cx, |this| {
if let Some(BlockKind::Image { width: at, .. }) =
this.doc.blocks.get_mut(ix).map(|block| &mut block.kind)
{
*at = width;
}
vec![]
});
if width.is_none() {
window.refresh();
}
} else {
cx.notify();
}
true
}
pub(super) fn resize_handle(
&self,
theme: &Theme,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let has_url = |ix: &usize| {
matches!(
self.doc.blocks.get(*ix).map(|block| &block.kind),
Some(BlockKind::Image { url, .. }) if !url.is_empty()
)
};
let ix = [self.resizing.map(|(ix, _)| ix), self.hovered]
.into_iter()
.flatten()
.find(has_url)?;
let dragging = self.resizing.is_some_and(|(resizing, _)| resizing == ix);
let current = match self.doc.blocks.get(ix).map(|block| &block.kind) {
Some(BlockKind::Image { width, .. }) => *width,
_ => None,
};
let picture = self.picture_box(ix, current)?;
Some(
theme
.split_handle(Axis::Horizontal, SplitStyle::Line { dragging })
.id("image-resize-handle")
.absolute()
.left(picture.origin.x + picture.size.width - px(4.5))
.top(picture.origin.y)
.h(picture.size.height)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _: &gpui::MouseDownEvent, _, cx| {
this.press_claimed = true;
this.resizing = Some((ix, current));
cx.notify();
}),
)
.into_any_element(),
)
}
pub(super) fn resize_preview(&self) -> Option<AnyElement> {
let (ix, live) = match self.resizing? {
(ix, Some(live)) => (ix, live),
_ => return None,
};
let Some(BlockKind::Image { url, .. }) = self.doc.blocks.get(ix).map(|block| &block.kind)
else {
return None;
};
let box_ = self.picture_box(ix, Some(live))?;
let picture = match url.contains("://") {
true => img(gpui::SharedString::from(url.to_string())),
false => img(std::path::PathBuf::from(url)),
};
Some(
div()
.absolute()
.left(box_.origin.x)
.top(box_.origin.y)
.w(box_.size.width)
.overflow_hidden()
.child(picture.w(box_.size.width))
.into_any_element(),
)
}
pub(super) fn url_prompt(&self, theme: &Theme, cx: &mut Context<Self>) -> Option<AnyElement> {
let prompt = self.url_prompt.as_ref()?;
Some(popover::menu_at(
"image-url",
prompt.at,
popover::popover_card(theme)
.w(px(PROMPT_WIDTH))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _: &gpui::MouseDownEvent, _, _| this.press_claimed = true),
)
.on_mouse_down_out(
cx.listener(|this, _, window, cx| this.cancel_url(&CancelUrl, window, cx)),
)
.child(
div()
.px(px(8.0))
.py(px(6.0))
.child(prompt.field.clone().into_any_element()),
)
.into_any_element(),
None,
))
}
}