388 lines
13 KiB
Rust
388 lines
13 KiB
Rust
use std::{path::Path, sync::Arc};
|
|
|
|
use gpui::{
|
|
AnyElement, AppContext as _, Bounds, Context, EmptyView, EntityId, Image, ImageFormat,
|
|
ImageSource, InteractiveElement, IntoElement, ParentElement, Pixels, SharedUri,
|
|
StatefulInteractiveElement, Styled, Window, div, img, prelude::*,
|
|
};
|
|
use regex::Regex;
|
|
|
|
use super::{Align, Block, BlockEditor, BlockKind, Media, view::Look};
|
|
use crate::{
|
|
buttons::{Button, ButtonVariant, IconButton},
|
|
primitives::{Icon, IconName, Measure},
|
|
theme::{ControlSize, IconSize},
|
|
};
|
|
|
|
/// Narrowest a picture gets, as a share of its column.
|
|
const NARROWEST: f32 = 0.2;
|
|
|
|
/// A picture's edge on its way, and whose.
|
|
struct Resize {
|
|
owner: EntityId,
|
|
key: u64,
|
|
}
|
|
|
|
/// A web address loads over the network; anything else is a file.
|
|
pub(crate) fn source(text: &str) -> ImageSource {
|
|
if text.starts_with("http://") || text.starts_with("https://") {
|
|
SharedUri::from(text.to_string()).into()
|
|
} else {
|
|
Path::new(text).into()
|
|
}
|
|
}
|
|
|
|
/// The share of `column` a picture set by `align` spans when its edge is at `x`.
|
|
pub(crate) fn share(align: Align, column: Bounds<Pixels>, x: Pixels) -> f32 {
|
|
let width = f32::from(column.size.width).max(1.0);
|
|
let reach = match align {
|
|
Align::Left => f32::from(x - column.left()),
|
|
Align::Right => f32::from(column.right() - x),
|
|
Align::Center => 2.0 * f32::from(x - column.center().x).abs(),
|
|
};
|
|
(reach / width).clamp(NARROWEST, 1.0)
|
|
}
|
|
|
|
/// A media block's media; a block turned into something else since its controls painted has none.
|
|
fn media_of(kind: &mut BlockKind) -> Option<&mut Media> {
|
|
match kind {
|
|
BlockKind::Image(media) | BlockKind::Video(media) | BlockKind::Embed(media) => Some(media),
|
|
other => {
|
|
log::error!("block editor: a {} block holds no media now", other.label());
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockEditor {
|
|
/// Sets a picture's width while its edge is dragged; the drag's start records undo.
|
|
pub(super) fn resize(&mut self, key: u64, width: f32, first: bool, cx: &mut Context<Self>) {
|
|
let Some(ix) = self.index(key) else {
|
|
return;
|
|
};
|
|
let mut kind = self.blocks[ix].kind.clone();
|
|
let Some(media) = media_of(&mut kind) else {
|
|
return;
|
|
};
|
|
media.width = width;
|
|
if first {
|
|
self.before_change(cx);
|
|
}
|
|
self.blocks[ix].kind = kind;
|
|
self.after_change(cx);
|
|
}
|
|
|
|
pub(crate) fn align(&mut self, key: u64, align: Align, cx: &mut Context<Self>) {
|
|
let Some(ix) = self.index(key) else {
|
|
return;
|
|
};
|
|
let mut kind = self.blocks[ix].kind.clone();
|
|
let Some(media) = media_of(&mut kind) else {
|
|
return;
|
|
};
|
|
media.align = align;
|
|
self.set_kind(key, kind, cx);
|
|
}
|
|
}
|
|
|
|
/// A picture sized as a share of its column, set left, center or right, with an edge to drag and a caption below; a clip or a page shows as a card to open.
|
|
pub(super) fn media(
|
|
editor: &BlockEditor,
|
|
block: &Block,
|
|
look: &Look,
|
|
window: &mut Window,
|
|
cx: &mut Context<BlockEditor>,
|
|
) -> AnyElement {
|
|
let (key, id) = (block.key, BlockEditor::id(cx));
|
|
let colors = look.colors.clone();
|
|
let caption = div()
|
|
.text_size(look.small)
|
|
.text_color(colors.fg_muted)
|
|
.child(editor.field_view(block, 0, window, cx));
|
|
let (media, icon) = match &block.kind {
|
|
BlockKind::Image(media) => (media.clone(), None),
|
|
BlockKind::Video(media) => (media.clone(), Some(IconName::Video)),
|
|
BlockKind::Embed(media) => (media.clone(), Some(IconName::Globe)),
|
|
other => unreachable!("{} is no media", other.label()),
|
|
};
|
|
if let Some(icon) = icon {
|
|
let address = media.source.clone();
|
|
return div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.p_3()
|
|
.rounded(look.radius)
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.child(Icon::new(icon).size(IconSize::Md).color(colors.fg_muted))
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.text_color(colors.fg_muted)
|
|
.child(media.source.clone()),
|
|
)
|
|
.child(
|
|
Button::new((id.clone(), format!("open-{key}")), "Open")
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.on_click(move |_, _, cx| cx.open_url(&address)),
|
|
),
|
|
)
|
|
.child(caption)
|
|
.into_any_element();
|
|
}
|
|
let column = window.use_keyed_state((id.clone(), format!("column-{key}")), cx, |_, _| {
|
|
Bounds::<Pixels>::default()
|
|
});
|
|
let bounds = *column.read(cx);
|
|
let (align, width) = (media.align, media.width);
|
|
let owner = cx.entity_id();
|
|
let edge = |side: &'static str, cx: &mut Context<BlockEditor>| {
|
|
let entity = cx.entity();
|
|
div()
|
|
.id((id.clone(), format!("edge-{key}-{side}")))
|
|
.absolute()
|
|
.top_0()
|
|
.bottom_0()
|
|
.map(|edge| {
|
|
if side == "left" {
|
|
edge.left_0()
|
|
} else {
|
|
edge.right_0()
|
|
}
|
|
})
|
|
.w_2()
|
|
.cursor_col_resize()
|
|
.on_drag(Resize { owner, key }, move |_, _, _, cx| {
|
|
entity.update(cx, |editor, cx| editor.resize(key, width, true, cx));
|
|
log::info!("image {key}: resize");
|
|
cx.new(|_| EmptyView)
|
|
})
|
|
};
|
|
let aligned = |align_to: Align, icon: IconName, cx: &mut Context<BlockEditor>| {
|
|
IconButton::new((id.clone(), format!("align-{key}-{align_to:?}")), icon)
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.tooltip(format!("Align {align_to:?}").to_lowercase())
|
|
.on_click(cx.listener(move |editor, _, _, cx| editor.align(key, align_to, cx)))
|
|
};
|
|
let tools = div()
|
|
.absolute()
|
|
.top_1()
|
|
.right_1()
|
|
.flex()
|
|
.gap_0p5()
|
|
.p_0p5()
|
|
.rounded(look.radius)
|
|
.bg(colors.overlay)
|
|
.opacity(0.0)
|
|
.group_hover(format!("picture-{key}"), |tools| tools.opacity(1.0))
|
|
.child(aligned(Align::Left, IconName::AlignLeft, cx))
|
|
.child(aligned(Align::Center, IconName::AlignCenter, cx))
|
|
.child(aligned(Align::Right, IconName::AlignRight, cx));
|
|
let picture = div()
|
|
.id((id.clone(), format!("picture-{key}")))
|
|
.group(format!("picture-{key}"))
|
|
.relative()
|
|
.w(bounds.size.width * width)
|
|
.child(
|
|
img(source(&media.source))
|
|
.id((id.clone(), format!("img-{key}")))
|
|
.w(bounds.size.width * width)
|
|
.rounded(look.radius),
|
|
)
|
|
.child(edge("left", cx))
|
|
.child(edge("right", cx))
|
|
.child(tools);
|
|
let entity = cx.entity();
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.id((id.clone(), format!("column-{key}")))
|
|
.w_full()
|
|
.on_drag_move(move |event: &gpui::DragMoveEvent<Resize>, _, cx| {
|
|
if event.drag(cx).owner != owner || event.drag(cx).key != key {
|
|
return;
|
|
}
|
|
let width = share(align, event.bounds, event.event.position.x);
|
|
entity.update(cx, |editor, cx| editor.resize(key, width, false, cx));
|
|
})
|
|
.child(
|
|
Measure::new(
|
|
(id.clone(), format!("measure-{key}")),
|
|
move |measured, _, cx| {
|
|
column.update(cx, |column, cx| {
|
|
*column = measured;
|
|
cx.notify();
|
|
})
|
|
},
|
|
)
|
|
.w_full()
|
|
.flex()
|
|
.map(|row| match align {
|
|
Align::Left => row.justify_start(),
|
|
Align::Center => row.justify_center(),
|
|
Align::Right => row.justify_end(),
|
|
})
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.w(bounds.size.width * width)
|
|
.child(picture)
|
|
.child(caption),
|
|
),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
/// A diagram the host drew, and the width its SVG asks for.
|
|
#[derive(Clone)]
|
|
struct Drawn {
|
|
image: Arc<Image>,
|
|
width: Option<f32>,
|
|
}
|
|
|
|
/// The width an SVG's root asks for, in pixels, when it gives one.
|
|
pub(crate) fn svg_width(svg: &str) -> Option<f32> {
|
|
let root = Regex::new(r#"<svg\b[^>]*?\swidth=['"]([0-9.]+)(px)?['"]"#)
|
|
.expect("the SVG width pattern compiles");
|
|
root.captures(svg)?.get(1)?.as_str().parse().ok()
|
|
}
|
|
|
|
/// A diagram's source above the picture the host draws from it.
|
|
pub(super) fn diagram(
|
|
editor: &BlockEditor,
|
|
block: &Block,
|
|
look: &Look,
|
|
window: &mut Window,
|
|
cx: &mut Context<BlockEditor>,
|
|
) -> AnyElement {
|
|
let (key, id) = (block.key, BlockEditor::id(cx));
|
|
let text = block.fields[0].read(cx).text().to_string();
|
|
let drawn = window.use_keyed_state((id.clone(), format!("drawn-{key}")), cx, |_, _| {
|
|
None::<(String, Result<Drawn, String>)>
|
|
});
|
|
let stale = drawn
|
|
.read(cx)
|
|
.as_ref()
|
|
.is_none_or(|(source, _)| *source != text);
|
|
if stale && let Some(render) = &editor.diagram {
|
|
let picture = render(&text).map(|svg| Drawn {
|
|
width: svg_width(&svg),
|
|
image: Arc::new(Image::from_bytes(ImageFormat::Svg, svg.into_bytes())),
|
|
});
|
|
log::info!(
|
|
"diagram {key}: drawn, {}",
|
|
if picture.is_ok() {
|
|
"fine"
|
|
} else {
|
|
"with an error"
|
|
}
|
|
);
|
|
drawn.update(cx, |drawn, _| *drawn = Some((text.clone(), picture)));
|
|
}
|
|
let colors = look.colors.clone();
|
|
let shown: AnyElement = match (editor.diagram.is_some(), drawn.read(cx).clone()) {
|
|
_ if text.trim().is_empty() => div().into_any_element(),
|
|
(false, _) => div()
|
|
.text_color(colors.fg_subtle)
|
|
.child("Diagrams draw once the app gives a renderer")
|
|
.into_any_element(),
|
|
(true, Some((_, Ok(drawn)))) => {
|
|
let column =
|
|
window.use_keyed_state((id.clone(), format!("diagram-width-{key}")), cx, |_, _| {
|
|
Pixels::ZERO
|
|
});
|
|
let width = drawn.width.map_or(*column.read(cx), |natural| {
|
|
Pixels::from(natural).min(*column.read(cx))
|
|
});
|
|
Measure::new(
|
|
(id.clone(), format!("diagram-measure-{key}")),
|
|
move |bounds, _, cx| {
|
|
column.update(cx, |column, cx| {
|
|
*column = bounds.size.width;
|
|
cx.notify();
|
|
})
|
|
},
|
|
)
|
|
.w_full()
|
|
.flex()
|
|
.justify_center()
|
|
.child(
|
|
img(drawn.image)
|
|
.id((id.clone(), format!("diagram-{key}")))
|
|
.w(width),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
(true, Some((_, Err(error)))) => div()
|
|
.text_color(colors.danger)
|
|
.child(error)
|
|
.into_any_element(),
|
|
(true, None) => unreachable!("a renderer draws before this shows"),
|
|
};
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_2()
|
|
.p_3()
|
|
.rounded(look.radius)
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.child(
|
|
div()
|
|
.font_family(look.mono.clone())
|
|
.text_size(look.small)
|
|
.text_color(colors.fg_muted)
|
|
.child(editor.field_view(block, 0, window, cx)),
|
|
)
|
|
.child(shown)
|
|
.into_any_element()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use gpui::{Bounds, point, px, size};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn an_svg_gives_its_width_when_it_has_one() {
|
|
assert_eq!(
|
|
svg_width("<svg xmlns='x' width='504' height='44'>"),
|
|
Some(504.0)
|
|
);
|
|
assert_eq!(svg_width("<svg width=\"120px\">"), Some(120.0));
|
|
assert_eq!(
|
|
svg_width("<svg viewBox='0 0 10 10'><rect width='5'/></svg>"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_edge_sets_its_share_by_how_the_picture_sits() {
|
|
let column = Bounds::new(point(px(100.0), px(0.0)), size(px(400.0), px(10.0)));
|
|
assert_eq!(share(Align::Left, column, px(300.0)), 0.5);
|
|
assert_eq!(share(Align::Right, column, px(300.0)), 0.5);
|
|
assert_eq!(share(Align::Center, column, px(400.0)), 0.5);
|
|
assert_eq!(
|
|
share(Align::Center, column, px(300.0)),
|
|
NARROWEST,
|
|
"a picture keeps some width"
|
|
);
|
|
}
|
|
}
|