use crate::AppExt as _;
use std::{
cell::RefCell,
collections::HashMap,
hash::{DefaultHasher, Hash, Hasher},
ops::Range,
path::Path,
rc::Rc,
};
use gpui::{
AnyElement, App, BorderStyle, Bounds, CursorStyle, ElementId, FontStyle, FontWeight, Hsla,
ImageSource, InteractiveText, MouseButton, ObjectFit, Pixels, Point, SharedString,
StrikethroughStyle, StyledImage as _, StyledText, TextLayout, TextRun, UnderlineStyle, Window,
canvas, div, font, img, point, prelude::*, px, quad, size,
};
use theme::{TextStyle, Theme, Typeset};
use crate::{
block,
doc::{Align, Block, BlockKind, Doc, Form, Mark, Part, QuoteKind, Text},
preview,
select::{Cursor, Selection},
typography::Typography,
};
mod code;
mod column;
mod layouts;
mod media;
mod table;
mod text;
pub use code::*;
use column::*;
pub use layouts::*;
use media::*;
use table::*;
pub use text::*;
const BLOCK_GAP: f32 = 12.0;
const LIST_GAP: f32 = 4.0;
const INDENT_WIDTH: f32 = 22.0;
const MARKER_WIDTH: f32 = 18.0;
const MARKER_GAP: f32 = 8.0;
const CODE_PADDING_X: f32 = 12.0;
const CODE_PADDING_Y: f32 = 10.0;
pub const PLAIN_LANGUAGE: &str = "Plain";
const CARET_WIDTH: f32 = 1.5;
const INLINE_CODE_RADIUS: f32 = 4.5;
const INLINE_CODE_PAD_X: f32 = 2.0;
const INLINE_CODE_INSET_Y: f32 = 2.0;
const MENTION_CARD_WIDTH: f32 = 320.0;
const CARD_HEIGHT: f32 = 116.0;
const CARD_IMAGE_WIDTH: f32 = 180.0;
const CARD_COVER_HEIGHT: f32 = 200.0;
const CARD_PADDING: f32 = 14.0;
const CARD_BORDER: f32 = 1.0;
const CARD_ICON: f32 = 16.0;
const CARD_COVER: f32 = 44.0;
const IMAGE_EMPTY_HEIGHT: f32 = 52.0;
const CAPTION_GAP: f32 = 4.0;
const IMAGE_EMPTY: &str = "Add an image";
const CAPTION_HINT: &str = "Write a caption";
const TABLE_CELL_PADDING: f32 = 12.0;
pub const TABLE_CONTROL_SIZE: f32 = 16.0;
const TABLE_DIVIDER: f32 = 1.0;
const TABLE_MIN_COLUMN_CONTENT: f32 = 48.0;
const TABLE_MIN_COLUMN_WIDTH: f32 = 96.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Caption {
#[default]
Shown,
Hidden,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum CopyButton {
#[default]
Shown,
Hidden,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
#[non_exhaustive]
pub enum Annotation {
#[default]
Open,
Resolved,
Active,
Highlight(crate::HighlightColor),
Match,
Current,
}
impl Annotation {
fn wash(self, theme: &Theme, highlight: crate::HighlightPaint, find: crate::FindPaint) -> Hsla {
match self {
Self::Open => theme.warning.opacity(0.20),
Self::Resolved => theme.warning.opacity(0.08),
Self::Active => theme.warning.opacity(0.38),
Self::Highlight(color) => highlight(color, theme),
Self::Match => find(theme).0,
Self::Current => find(theme).1,
}
}
}
pub type OnToggle = Rc<dyn Fn(usize, &mut Window, &mut App)>;
pub type OnImage = Rc<dyn Fn(usize, &mut Window, &mut App)>;
pub type ImageOverlay = Rc<dyn Fn(usize, &str, &mut Window, &mut App) -> Option<AnyElement>>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ImageOverlayCorner {
TopLeft,
TopRight,
BottomLeft,
#[default]
BottomRight,
}
#[derive(Clone)]
pub enum Toggle {
Handled(OnToggle),
HitTested,
}
#[derive(Clone)]
pub struct Editing<'a> {
pub selection: Option<Selection>,
pub caret_on: bool,
pub layouts: Option<&'a BlockLayouts>,
pub annotations: &'a [(Selection, Annotation)],
pub placeholder: Option<SharedString>,
pub caption: Caption,
pub typography: Option<Typography>,
pub toggle: Option<Toggle>,
pub image: Option<OnImage>,
pub image_overlay: Option<ImageOverlay>,
pub image_overlay_corner: ImageOverlayCorner,
pub table_controls: bool,
pub copy: CopyButton,
pub base: Option<&'a Path>,
pub keep: &'a [usize],
pub scroll: Option<&'a gpui::ScrollHandle>,
}
impl Default for Editing<'_> {
fn default() -> Self {
Self {
selection: None,
caret_on: true,
layouts: None,
annotations: &[],
placeholder: None,
caption: Caption::default(),
typography: None,
toggle: None,
image: None,
image_overlay: None,
image_overlay_corner: ImageOverlayCorner::BottomRight,
table_controls: false,
copy: CopyButton::default(),
base: None,
keep: &[],
scroll: None,
}
}
}
#[derive(Clone, Copy)]
struct Overlay<'a> {
block: usize,
part: Part,
selection: Option<Selection>,
caret_on: bool,
layouts: Option<&'a BlockLayouts>,
annotations: &'a [(Selection, Annotation)],
placeholder: Option<&'a SharedString>,
caption: Caption,
toggle: Option<&'a Toggle>,
image: Option<&'a OnImage>,
image_overlay: Option<&'a ImageOverlay>,
image_overlay_corner: ImageOverlayCorner,
table_controls: bool,
copy: CopyButton,
base: Option<&'a Path>,
highlight: crate::HighlightPaint,
find: crate::FindPaint,
}
impl<'a> Overlay<'a> {
fn at(self, part: Part) -> Self {
Self { part, ..self }
}
fn here(&self) -> Cursor {
Cursor::new(self.block, self.part, 0)
}
fn caret_painted(&self) -> Option<usize> {
self.caret_on.then(|| self.caret()).flatten()
}
fn caret(&self) -> Option<usize> {
self.selection
.map(|selection| selection.head)
.filter(|head| head.block == self.block && head.part == self.part)
.map(|head| head.offset)
}
fn selected(&self, len: usize) -> Option<Range<usize>> {
self.clip(self.selection?, len)
}
fn annotated(&self, len: usize, theme: &Theme) -> Vec<(Range<usize>, Hsla)> {
self.annotations
.iter()
.filter_map(|(range, kind)| {
Some((
self.clip(*range, len)?,
kind.wash(theme, self.highlight, self.find),
))
})
.collect()
}
fn clip(&self, selection: Selection, len: usize) -> Option<Range<usize>> {
if selection.is_collapsed() {
return None;
}
let (start, end) = selection.ordered();
let here = self.here();
let (first, last) = (
Cursor::new(start.block, start.part, 0),
Cursor::new(end.block, end.part, 0),
);
if here < first || here > last {
return None;
}
let from = if here == first { start.offset } else { 0 };
let to = if here == last { end.offset } else { len };
(from < to).then_some(from..to.min(len))
}
fn covers_block(&self) -> bool {
let Some(selection) = self.selection.filter(|s| !s.is_collapsed()) else {
return false;
};
let (start, end) = selection.ordered();
start.block < self.block && self.block < end.block
}
}
pub fn image_source(url: &str, base: Option<&Path>) -> ImageSource {
if url.contains("://") {
return SharedString::from(url.to_string()).into();
}
match base {
Some(base) => base.join(url).into(),
None => std::path::PathBuf::from(url).into(),
}
}
pub fn markdown(source: &str, window: &mut Window, cx: &mut App) -> AnyElement {
let doc = crate::parse_with(source, &cx.marks());
render(&doc, Caption::default(), window, cx)
}
pub fn render(doc: &Doc, caption: Caption, window: &mut Window, cx: &mut App) -> AnyElement {
render_with(
doc,
Editing {
caption,
..Editing::default()
},
window,
cx,
)
}
pub fn render_with(doc: &Doc, editing: Editing, window: &mut Window, cx: &mut App) -> AnyElement {
let Editing {
selection,
caret_on,
layouts,
annotations,
placeholder,
caption,
typography,
toggle,
image,
image_overlay,
image_overlay_corner,
table_controls,
copy,
base,
keep,
scroll,
} = editing;
let theme = Theme::of(cx).clone();
let typography = typography.unwrap_or_else(|| cx.typography());
let highlight = crate::marks::highlight_paint_of(cx);
let find = crate::find::find_paint_of(cx);
let gaps: Vec<Pixels> = doc
.blocks
.iter()
.enumerate()
.map(|(ix, block)| {
px(match doc.blocks.get(ix.wrapping_sub(1)) {
None => 0.0,
Some(previous) if tight(previous, block) => LIST_GAP,
Some(_) => BLOCK_GAP,
})
})
.collect();
let Some(layouts) = layouts else {
let mut column = div().flex().flex_col();
for (ix, block) in doc.blocks.iter().enumerate() {
let overlay = Overlay {
block: ix,
part: Part::Body,
selection,
caret_on,
layouts: None,
annotations,
placeholder: placeholder.as_ref(),
caption,
toggle: toggle.as_ref(),
image: image.as_ref(),
image_overlay: image_overlay.as_ref(),
image_overlay_corner,
table_controls,
copy,
base,
highlight,
find,
};
column = column
.child(block_box(block, overlay, &typography, &theme, window, cx).mt(gaps[ix]));
}
return column.into_any_element();
};
let keys: Vec<u64> = doc
.blocks
.iter()
.map(|block| block_key(block, &typography, table_controls))
.collect();
layouts.prune(&keys);
let guesses: Vec<Guess> = doc
.blocks
.iter()
.map(|block| {
let mut guess = guess(block, &typography);
if table_controls && matches!(block.kind, BlockKind::Table { .. }) {
guess.extra += px(2.0 * TABLE_CONTROL_SIZE);
}
guess
})
.collect();
let mut kept = keep.to_vec();
kept.extend(selection.map(|selection| selection.head.block));
kept.extend(selection.map(|selection| selection.anchor.block));
let owned = Owned {
blocks: doc.blocks.clone(),
selection,
caret_on,
layouts: layouts.clone(),
annotations: annotations.to_vec(),
placeholder,
caption,
toggle,
image,
image_overlay,
image_overlay_corner,
table_controls,
copy,
base: base.map(Path::to_path_buf),
highlight,
find,
typography,
theme,
};
Column {
layouts: layouts.clone(),
keys: keys.into(),
gaps: gaps.into(),
guesses: guesses.into(),
keep: kept,
scroll: scroll.cloned(),
item_of: Box::new(|at| at.block),
build: Box::new(move |ix, window, cx| {
let overlay = Overlay {
block: ix,
part: Part::Body,
selection: owned.selection,
caret_on: owned.caret_on,
layouts: Some(&owned.layouts),
annotations: &owned.annotations,
placeholder: owned.placeholder.as_ref(),
caption: owned.caption,
toggle: owned.toggle.as_ref(),
image: owned.image.as_ref(),
image_overlay: owned.image_overlay.as_ref(),
image_overlay_corner: owned.image_overlay_corner,
table_controls: owned.table_controls,
copy: owned.copy,
base: owned.base.as_deref(),
highlight: owned.highlight,
find: owned.find,
};
block_box(
&owned.blocks[ix],
overlay,
&owned.typography,
&owned.theme,
window,
cx,
)
.into_any_element()
}),
}
.into_any_element()
}
struct Owned {
blocks: Vec<Block>,
selection: Option<Selection>,
caret_on: bool,
layouts: BlockLayouts,
annotations: Vec<(Selection, Annotation)>,
placeholder: Option<SharedString>,
caption: Caption,
toggle: Option<Toggle>,
image: Option<OnImage>,
image_overlay: Option<ImageOverlay>,
image_overlay_corner: ImageOverlayCorner,
table_controls: bool,
copy: CopyButton,
base: Option<std::path::PathBuf>,
highlight: crate::HighlightPaint,
find: crate::FindPaint,
typography: Typography,
theme: Theme,
}
fn block_box(
block: &Block,
overlay: Overlay,
typography: &Typography,
theme: &Theme,
window: &mut Window,
cx: &mut App,
) -> gpui::Div {
let ix = overlay.block;
let frame = overlay.layouts.map(|layouts| {
let layouts = layouts.clone();
canvas(
move |bounds, _, _| layouts.record_block(ix, bounds),
|_, _, _, _| (),
)
.absolute()
.size_full()
});
div()
.w_full()
.pl(px(block.indent as f32 * INDENT_WIDTH))
.child(
div()
.w_full()
.relative()
.children(frame)
.when(overlay.covers_block() && block.opaque(), |el| {
el.rounded(px(4.0)).bg(theme.selection)
})
.child(block_element(block, overlay, typography, theme, window, cx)),
)
}
fn block_key(block: &Block, typography: &Typography, table_controls: bool) -> u64 {
let mut hasher = DefaultHasher::new();
table_controls.hash(&mut hasher);
block.hash(&mut hasher);
typography.body.size().to_bits().hash(&mut hasher);
typography.body.line_height().to_bits().hash(&mut hasher);
hasher.finish()
}
fn guess(block: &Block, typography: &Typography) -> Guess {
let body = px(typography.body.line_height());
let prose = |chars: usize, line: Pixels| Guess {
chars,
line,
rows: 0,
extra: px(0.0),
indent: px(0.0),
};
let guess = match &block.kind {
BlockKind::Paragraph(text)
| BlockKind::Bullet(text)
| BlockKind::Ordered { text, .. }
| BlockKind::Task { text, .. }
| BlockKind::Quote { text, .. } => prose(text.text.len(), body),
BlockKind::Heading { level, text } => prose(
text.text.len(),
px(typography.heading(*level).line_height()),
),
BlockKind::Code { code, .. } => Guess {
rows: code.text.lines().count().max(1),
line: px(typography.code.line_height()),
extra: px(2.0 * CODE_PADDING_Y) + body,
..prose(0, body)
},
BlockKind::Table { rows, .. } => Guess {
rows: rows.len() + 1,
extra: px(2.0 * TABLE_CELL_PADDING + TABLE_DIVIDER) * (rows.len() + 1) as f32,
..prose(0, body)
},
BlockKind::Image { alt, .. } => Guess {
extra: px(240.0),
..prose(alt.text.len(), px(typography.caption.line_height()))
},
BlockKind::Bookmark { .. } => Guess {
extra: body * 3.0,
..prose(0, body)
},
BlockKind::Rule => Guess {
extra: body,
..prose(0, px(0.0))
},
};
Guess {
indent: px(block.indent as f32 * INDENT_WIDTH),
..guess
}
}
fn tight(previous: &Block, next: &Block) -> bool {
let marker = |block: &Block| {
matches!(
block.kind,
BlockKind::Bullet(_) | BlockKind::Ordered { .. } | BlockKind::Task { .. }
)
};
marker(previous) && (marker(next) || next.indent > previous.indent)
}
fn block_element(
block: &Block,
overlay: Overlay,
typography: &Typography,
theme: &Theme,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let body = overlay.at(Part::Body);
match &block.kind {
BlockKind::Paragraph(text) => text_element(
text,
typography.body.size(),
typography.body.line_height(),
FontWeight::NORMAL,
body,
theme,
cx,
),
BlockKind::Heading { level, text } => {
let heading = typography.heading(*level);
text_element(
text,
heading.size(),
heading.line_height(),
heading.weight,
body,
theme,
cx,
)
}
BlockKind::Bullet(text) => {
marker_row(disc(typography, theme), text, body, typography, theme, cx)
}
BlockKind::Ordered { number, text } => marker_row(
div()
.flex_none()
.w(px(MARKER_WIDTH))
.text_size(px(typography.body.size()))
.line_height(px(typography.body.line_height()))
.text_color(theme.text_muted)
.child(SharedString::from(format!("{number}.")))
.into_any_element(),
text,
body,
typography,
theme,
cx,
),
BlockKind::Task { checked, text } => marker_row(
checkbox(*checked, overlay, typography, theme),
text,
body,
typography,
theme,
cx,
),
BlockKind::Quote { kind, text } => div()
.border_l_2()
.border_color(kind.map_or(theme.border_strong, |kind| alert_color(kind, theme)))
.pl(px(12.0))
.pr(px(10.0))
.py(px(2.0))
.text_color(theme.text_muted)
.children(kind.map(|kind| {
div()
.pb(px(2.0))
.text_size(px(typography.body.size()))
.line_height(px(typography.body.line_height()))
.font_weight(FontWeight::SEMIBOLD)
.text_color(alert_color(kind, theme))
.child(kind.label())
}))
.child(text_element(
text,
typography.body.size(),
typography.body.line_height(),
FontWeight::NORMAL,
body,
theme,
cx,
))
.into_any_element(),
BlockKind::Code { language, code } => {
let overlay = overlay.at(Part::Code);
let painted = overlay
.caret()
.is_none()
.then(|| block::render(language.as_deref(), &code.text, window, cx))
.flatten();
match painted {
Some(element) => div()
.when(overlay.covers_block(), |el| {
el.rounded(px(4.0)).bg(theme.selection)
})
.child(element)
.into_any_element(),
None => code_block(
language.as_deref(),
&code.text,
overlay,
typography,
theme,
window,
cx,
),
}
}
BlockKind::Image { url, alt, width } => {
image(url, alt, *width, overlay, typography, theme, window, cx)
}
BlockKind::Bookmark { url, form } => {
bookmark(overlay.block, url, *form, typography, theme, cx)
}
BlockKind::Table {
align,
header,
rows,
} => table(align, header, rows, overlay, typography, theme, window, cx),
BlockKind::Rule => div()
.h(px(1.0))
.w_full()
.bg(theme.border)
.into_any_element(),
}
}
fn disc(typography: &Typography, theme: &Theme) -> AnyElement {
div()
.flex_none()
.w(px(MARKER_WIDTH))
.h(px(typography.body.line_height()))
.flex()
.items_center()
.child(
div()
.ml(px(1.0))
.w(px(5.0))
.h(px(5.0))
.rounded_full()
.bg(theme.text_faint),
)
.into_any_element()
}
fn checkbox(checked: bool, overlay: Overlay, typography: &Typography, theme: &Theme) -> AnyElement {
let ix = overlay.block;
let mut box_ = div()
.relative()
.w(px(13.0))
.h(px(13.0))
.rounded(px(3.5))
.border_1()
.flex()
.items_center()
.justify_center();
box_ = if checked {
box_.bg(theme.solid)
.border_color(theme.solid)
.text_style(TextStyle::Caption)
.text_color(theme.on_solid)
.child("✓")
} else {
box_.border_color(theme.border_strong)
};
box_ = box_.children(overlay.layouts.map(|layouts| {
let layouts = layouts.clone();
canvas(
move |bounds, _, _| layouts.record_checkbox(ix, bounds),
|_, _, _, _| (),
)
.absolute()
.size_full()
}));
if overlay.toggle.is_some() {
box_ = box_.cursor_pointer();
}
if let Some(Toggle::Handled(toggle)) = overlay.toggle.cloned() {
box_ = box_.on_mouse_down(MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
toggle(ix, window, cx);
});
}
div()
.flex_none()
.w(px(MARKER_WIDTH))
.h(px(typography.body.line_height()))
.flex()
.items_center()
.child(box_)
.into_any_element()
}
fn alert_color(kind: QuoteKind, theme: &Theme) -> Hsla {
match kind {
QuoteKind::Note => theme.accent,
QuoteKind::Tip => theme.success,
QuoteKind::Important => theme.busy,
QuoteKind::Warning => theme.warning,
QuoteKind::Caution => theme.danger,
}
}
fn marker_row(
marker: AnyElement,
text: &Text,
overlay: Overlay,
typography: &Typography,
theme: &Theme,
cx: &App,
) -> AnyElement {
div()
.flex()
.flex_row()
.gap(px(MARKER_GAP))
.child(marker)
.child(div().flex_1().min_w_0().child(text_element(
text,
typography.body.size(),
typography.body.line_height(),
FontWeight::NORMAL,
overlay,
theme,
cx,
)))
.into_any_element()
}