Documents: inline formulas typeset in text; \( and \[ read as math
This commit is contained in:
12 files changed
+569
-61
No files matched your search
@@ -5,7 +5,7 @@ use std::ops::Range;
|
|||||||
use markdown::mdast::Node;
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
use super::{Change, EditRefusal, after, block, cut, holder, marks, room};
|
use super::{Change, EditRefusal, after, block, cut, holder, marks, room};
|
||||||
use crate::documents::{markdown::span, tree::inside};
|
use crate::documents::markdown::{math_inside, span};
|
||||||
|
|
||||||
/// The backslashes of escapes in a text node's span.
|
/// The backslashes of escapes in a text node's span.
|
||||||
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
|
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
|
||||||
@@ -33,7 +33,7 @@ fn meta(node: &Node, text: &str, autolinks: &[Range<usize>]) -> Vec<Range<usize>
|
|||||||
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
|
Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(),
|
||||||
Node::Text(_) => escapes(text, whole),
|
Node::Text(_) => escapes(text, whole),
|
||||||
Node::InlineMath(_) => {
|
Node::InlineMath(_) => {
|
||||||
let inner = inside(text, whole.clone(), '$');
|
let inner = math_inside(text, whole.clone());
|
||||||
vec![whole.start..inner.start, inner.end..whole.end]
|
vec![whole.start..inner.start, inner.end..whole.end]
|
||||||
}
|
}
|
||||||
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
|
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::ops::Range;
|
use std::{borrow::Cow, ops::Range};
|
||||||
|
|
||||||
use gpui::App;
|
use gpui::App;
|
||||||
use markdown::{Constructs, ParseOptions, mdast};
|
use markdown::{Constructs, ParseOptions, mdast};
|
||||||
@@ -20,19 +20,92 @@ pub(crate) fn options() -> ParseOptions {
|
|||||||
|
|
||||||
/// `text` as a syntax tree.
|
/// `text` as a syntax tree.
|
||||||
pub(crate) fn tree(text: &str) -> mdast::Node {
|
pub(crate) fn tree(text: &str) -> mdast::Node {
|
||||||
markdown::to_mdast(text, &options()).expect("markdown without MDX always parses")
|
parsed(text, &options())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsed(text: &str, options: &ParseOptions) -> mdast::Node {
|
||||||
|
let text = tex_math(text, options);
|
||||||
|
markdown::to_mdast(&text, options).expect("markdown without MDX always parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `\(` and `\[` delimiters as `$`, byte for byte.
|
||||||
|
fn tex_math<'a>(text: &'a str, options: &ParseOptions) -> Cow<'a, str> {
|
||||||
|
if !text.contains("\\(") && !text.contains("\\[") {
|
||||||
|
return Cow::Borrowed(text);
|
||||||
|
}
|
||||||
|
let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses");
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let mut kept = vec![false; text.len()];
|
||||||
|
for node in nodes {
|
||||||
|
use mdast::Node::{Code, Html, InlineCode, InlineMath, Math, Toml, Yaml};
|
||||||
|
if matches!(
|
||||||
|
node,
|
||||||
|
Code(_) | InlineCode(_) | Math(_) | InlineMath(_) | Html(_) | Yaml(_) | Toml(_)
|
||||||
|
) {
|
||||||
|
kept[span(node)].fill(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut out = bytes.to_vec();
|
||||||
|
let mut open: Option<(usize, u8)> = None;
|
||||||
|
let mut at = 0;
|
||||||
|
while at + 1 < bytes.len() {
|
||||||
|
match bytes[at] {
|
||||||
|
// A blank line ends a paragraph and its formula.
|
||||||
|
b'\n' if blank_after(&bytes[at + 1..]) => open = None,
|
||||||
|
b'\\' if !kept[at] => {
|
||||||
|
match (bytes[at + 1], open) {
|
||||||
|
(b'(', None) => open = Some((at, b')')),
|
||||||
|
(b'[', None) => open = Some((at, b']')),
|
||||||
|
(close, Some((start, wanted)))
|
||||||
|
if close == wanted && at > start + 2 && !text[start..at].contains('$') =>
|
||||||
|
{
|
||||||
|
out[start..start + 2].copy_from_slice(b"$ ");
|
||||||
|
out[at..at + 2].copy_from_slice(b" $");
|
||||||
|
open = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// An escaped backslash escapes nothing after it.
|
||||||
|
at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
match out == bytes {
|
||||||
|
true => Cow::Borrowed(text),
|
||||||
|
false => Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the line `rest` starts holds only white space.
|
||||||
|
fn blank_after(rest: &[u8]) -> bool {
|
||||||
|
rest.iter()
|
||||||
|
.take_while(|byte| **byte != b'\n')
|
||||||
|
.all(u8::is_ascii_whitespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An inline formula's TeX: inside `$` runs or `\(` pairs.
|
||||||
|
pub(crate) fn math_inside(source: &str, whole: Range<usize>) -> Range<usize> {
|
||||||
|
match source[whole.clone()].starts_with('$') {
|
||||||
|
true => crate::documents::tree::inside(source, whole, '$'),
|
||||||
|
false => whole.start + 2..whole.end - 2,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a whole file parses: front matter too.
|
/// How a whole file parses: front matter too.
|
||||||
pub fn file_options() -> ParseOptions {
|
pub(crate) fn file_options() -> ParseOptions {
|
||||||
let mut options = options();
|
let mut options = options();
|
||||||
options.constructs.frontmatter = true;
|
options.constructs.frontmatter = true;
|
||||||
options
|
options
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A whole file as a syntax tree.
|
/// A whole file as a syntax tree.
|
||||||
pub(crate) fn file_tree(text: &str) -> mdast::Node {
|
pub fn file_tree(text: &str) -> mdast::Node {
|
||||||
markdown::to_mdast(text, &file_options()).expect("markdown without MDX always parses")
|
parsed(text, &file_options())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bytes of the source a parsed node spans.
|
/// The bytes of the source a parsed node spans.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub(crate) use history::Change;
|
|||||||
pub use history::{PageHistoryDiff, Version, VersionHistory};
|
pub use history::{PageHistoryDiff, Version, VersionHistory};
|
||||||
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
|
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
|
||||||
pub use link::LinkEditor;
|
pub use link::LinkEditor;
|
||||||
pub use markdown::file_options as markdown_options;
|
pub use markdown::file_tree as markdown_tree;
|
||||||
pub use markdown::markdown_highlights;
|
pub use markdown::markdown_highlights;
|
||||||
pub use modes::{MarkdownEditor, MarkdownMode};
|
pub use modes::{MarkdownEditor, MarkdownMode};
|
||||||
pub use pages::{PageCover, PageIcon};
|
pub use pages::{PageCover, PageIcon};
|
||||||
|
|||||||
+48
-25
@@ -3,7 +3,7 @@ use std::{path::Path, rc::Rc, sync::Arc};
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
|
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
|
||||||
StyledText, UnderlineStyle, Window, div, prelude::*, relative,
|
StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -21,6 +21,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod blocks;
|
mod blocks;
|
||||||
|
mod math_text;
|
||||||
mod point;
|
mod point;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
@@ -170,6 +171,38 @@ fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `inline` as text; `hidden` ranges lose their highlight.
|
||||||
|
fn drawn_text(
|
||||||
|
inline: &Inline,
|
||||||
|
hidden: &[std::ops::Range<usize>],
|
||||||
|
links: Option<ElementId>,
|
||||||
|
on_link: &Option<OnLink>,
|
||||||
|
cx: &App,
|
||||||
|
) -> (AnyElement, TextLayout) {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let rule = theme.underline_thickness();
|
||||||
|
let styles: Vec<_> = (inline.styles.iter())
|
||||||
|
.filter(|(range, _)| !hidden.contains(range))
|
||||||
|
.map(|(range, style)| (range.clone(), highlight(style, colors, rule)))
|
||||||
|
.collect();
|
||||||
|
let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
||||||
|
let layout = styled.layout().clone();
|
||||||
|
let drawn = match links {
|
||||||
|
None => styled.into_any_element(),
|
||||||
|
Some(id) => {
|
||||||
|
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
||||||
|
let on_link = on_link.clone();
|
||||||
|
InteractiveText::new(id, styled)
|
||||||
|
.on_click(ranges, move |ix, window, cx| {
|
||||||
|
open_link(&on_link, &urls[ix], window, cx)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(drawn, layout)
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws nodes under one owner's ids and hooks.
|
/// Draws nodes under one owner's ids and hooks.
|
||||||
struct Draw<'a> {
|
struct Draw<'a> {
|
||||||
id: &'a ElementId,
|
id: &'a ElementId,
|
||||||
@@ -198,30 +231,20 @@ impl Draw<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let links = (!inline.links.is_empty()).then(|| self.key("links"));
|
||||||
let colors = &theme.colors;
|
if !inline.styles.iter().any(|(_, style)| *style == Style::Math) {
|
||||||
let rule = theme.underline_thickness();
|
let (drawn, layout) = drawn_text(inline, &[], links, self.hooks.on_link, cx);
|
||||||
let styles: Vec<_> = inline
|
let at = move |position| {
|
||||||
.styles
|
layout
|
||||||
.iter()
|
.index_for_position(position)
|
||||||
.map(|(range, style)| (range.clone(), highlight(style, colors, rule)))
|
.unwrap_or_else(|near| near)
|
||||||
.collect();
|
};
|
||||||
let styled =
|
return self.point_text(drawn, at, inline);
|
||||||
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
}
|
||||||
let layout = styled.layout().clone();
|
let on_link = self.hooks.on_link.clone();
|
||||||
let drawn = match inline.links.is_empty() {
|
let (drawn, at) =
|
||||||
true => styled.into_any_element(),
|
math_text::MathText::new(self.key("math"), inline.clone(), links, on_link);
|
||||||
false => {
|
self.point_text(drawn.into_any_element(), at, inline)
|
||||||
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
|
|
||||||
let on_link = self.hooks.on_link.clone();
|
|
||||||
InteractiveText::new(self.key("links"), styled)
|
|
||||||
.on_click(ranges, move |ix, window, cx| {
|
|
||||||
open_link(&on_link, &urls[ix], window, cx)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.point_text(drawn, layout, inline)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
//! Inline formulas typeset among a paragraph's words.
|
||||||
|
|
||||||
|
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, InspectorElementId,
|
||||||
|
IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size, TextLayout,
|
||||||
|
TextStyleRefinement, Window, point,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{OnLink, drawn_text};
|
||||||
|
use crate::{
|
||||||
|
documents::tree::{Inline, Style},
|
||||||
|
typography::typeset,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Glue that reserves a formula's width and never breaks.
|
||||||
|
const GLUE: char = '\u{00A0}';
|
||||||
|
|
||||||
|
/// An original span and the drawn span standing for it.
|
||||||
|
type Swap = (Range<usize>, Range<usize>);
|
||||||
|
|
||||||
|
/// The drawn text and how its bytes map back.
|
||||||
|
struct Shown {
|
||||||
|
layout: TextLayout,
|
||||||
|
swaps: Vec<Swap>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paragraph text whose formulas draw typeset over reserved glue.
|
||||||
|
pub(super) struct MathText {
|
||||||
|
id: ElementId,
|
||||||
|
inline: Inline,
|
||||||
|
links: Option<ElementId>,
|
||||||
|
on_link: Option<OnLink>,
|
||||||
|
shown: Rc<RefCell<Option<Shown>>>,
|
||||||
|
text: Option<AnyElement>,
|
||||||
|
/// Each picture, its size, and the drawn span it covers.
|
||||||
|
pictures: Vec<(Arc<RenderImage>, Size<Pixels>, Range<usize>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MathText {
|
||||||
|
/// The element and how a point finds its original byte.
|
||||||
|
pub(super) fn new(
|
||||||
|
id: ElementId,
|
||||||
|
inline: Inline,
|
||||||
|
links: Option<ElementId>,
|
||||||
|
on_link: Option<OnLink>,
|
||||||
|
) -> (Self, impl Fn(Point<Pixels>) -> usize + 'static) {
|
||||||
|
let shown = Rc::new(RefCell::new(None));
|
||||||
|
let read = shown.clone();
|
||||||
|
let at = move |position| match &*read.borrow() {
|
||||||
|
Some(Shown { layout, swaps }) => back(
|
||||||
|
swaps,
|
||||||
|
layout
|
||||||
|
.index_for_position(position)
|
||||||
|
.unwrap_or_else(|near| near),
|
||||||
|
),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
let text = Self {
|
||||||
|
id,
|
||||||
|
inline,
|
||||||
|
links,
|
||||||
|
on_link,
|
||||||
|
shown,
|
||||||
|
text: None,
|
||||||
|
pictures: Vec::new(),
|
||||||
|
};
|
||||||
|
(text, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where original byte `at` is drawn.
|
||||||
|
fn forward(swaps: &[Swap], at: usize) -> usize {
|
||||||
|
let mut shift = 0isize;
|
||||||
|
for (written, drawn) in swaps {
|
||||||
|
if at < written.start {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if at < written.end {
|
||||||
|
return drawn.start;
|
||||||
|
}
|
||||||
|
shift = drawn.end as isize - written.end as isize;
|
||||||
|
}
|
||||||
|
(at as isize + shift) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original byte drawn byte `at` stands for.
|
||||||
|
fn back(swaps: &[Swap], at: usize) -> usize {
|
||||||
|
let mut shift = 0isize;
|
||||||
|
for (written, drawn) in swaps {
|
||||||
|
if at < drawn.start {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if at < drawn.end {
|
||||||
|
return written.start;
|
||||||
|
}
|
||||||
|
shift = written.end as isize - drawn.end as isize;
|
||||||
|
}
|
||||||
|
(at as isize + shift) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inline` with each swapped span drawn as `glue[ix]` glue.
|
||||||
|
fn shown(inline: &Inline, swaps: &mut [Swap], glue: &[usize]) -> Inline {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut from = 0;
|
||||||
|
for ((written, drawn), count) in swaps.iter_mut().zip(glue) {
|
||||||
|
text.push_str(&inline.text[from..written.start]);
|
||||||
|
let start = text.len();
|
||||||
|
text.extend(std::iter::repeat_n(GLUE, *count));
|
||||||
|
*drawn = start..text.len();
|
||||||
|
from = written.end;
|
||||||
|
}
|
||||||
|
text.push_str(&inline.text[from..]);
|
||||||
|
let span = |range: &Range<usize>| forward(swaps, range.start)..forward(swaps, range.end);
|
||||||
|
Inline {
|
||||||
|
text,
|
||||||
|
styles: (inline.styles.iter())
|
||||||
|
.map(|(range, style)| (span(range), *style))
|
||||||
|
.collect(),
|
||||||
|
links: (inline.links.iter())
|
||||||
|
.map(|(range, url)| (span(range), url.clone()))
|
||||||
|
.collect(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for MathText {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for MathText {
|
||||||
|
type RequestLayoutState = ();
|
||||||
|
type PrepaintState = ();
|
||||||
|
|
||||||
|
fn id(&self) -> Option<ElementId> {
|
||||||
|
Some(self.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&GlobalElementId>,
|
||||||
|
_: Option<&InspectorElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> (LayoutId, ()) {
|
||||||
|
let style = window.text_style();
|
||||||
|
let rem = window.rem_size();
|
||||||
|
let font = style.font_size.to_pixels(rem);
|
||||||
|
let glue = String::from(GLUE);
|
||||||
|
let run = style.to_run(glue.len());
|
||||||
|
let width = window
|
||||||
|
.text_system()
|
||||||
|
.shape_line(glue.into(), font, &[run], None)
|
||||||
|
.width;
|
||||||
|
let mut maths: Vec<Range<usize>> = (self.inline.styles.iter())
|
||||||
|
.filter(|(_, style)| *style == Style::Math)
|
||||||
|
.map(|(range, _)| range.clone())
|
||||||
|
.collect();
|
||||||
|
maths.sort_by_key(|range| range.start);
|
||||||
|
let mut swaps = Vec::new();
|
||||||
|
let mut counts = Vec::new();
|
||||||
|
let mut pictures = Vec::new();
|
||||||
|
for (ix, range) in maths.into_iter().enumerate() {
|
||||||
|
let source = SharedString::from(self.inline.text[range.clone()].to_string());
|
||||||
|
match typeset(
|
||||||
|
(self.id.clone(), SharedString::from(format!("formula-{ix}"))),
|
||||||
|
(&source, false),
|
||||||
|
font,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
) {
|
||||||
|
Some(Ok((image, size))) => {
|
||||||
|
counts.push((size.width / width).ceil().max(1.0) as usize);
|
||||||
|
pictures.push((image, size));
|
||||||
|
swaps.push((range, 0..0));
|
||||||
|
}
|
||||||
|
// The formula is the user's text: not logged.
|
||||||
|
Some(Err(_)) => log::warn!("markdown: an inline formula shows as TeX, unset"),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inline = shown(&self.inline, &mut swaps, &counts);
|
||||||
|
let hidden: Vec<Range<usize>> = swaps.iter().map(|(_, drawn)| drawn.clone()).collect();
|
||||||
|
let (mut text, layout) =
|
||||||
|
drawn_text(&inline, &hidden, self.links.clone(), &self.on_link, cx);
|
||||||
|
let tallest = pictures
|
||||||
|
.iter()
|
||||||
|
.map(|(_, size)| size.height)
|
||||||
|
.fold(Pixels::ZERO, Pixels::max);
|
||||||
|
// A tall formula opens the paragraph's lines.
|
||||||
|
let taller = (tallest > style.line_height_in_pixels(rem)).then(|| TextStyleRefinement {
|
||||||
|
line_height: Some(tallest.into()),
|
||||||
|
..TextStyleRefinement::default()
|
||||||
|
});
|
||||||
|
let id = window.with_text_style(taller, |window| text.request_layout(window, cx));
|
||||||
|
self.pictures = (pictures.into_iter().zip(&hidden))
|
||||||
|
.map(|((image, size), drawn)| (image, size, drawn.clone()))
|
||||||
|
.collect();
|
||||||
|
*self.shown.borrow_mut() = Some(Shown { layout, swaps });
|
||||||
|
self.text = Some(text);
|
||||||
|
(id, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&GlobalElementId>,
|
||||||
|
_: Option<&InspectorElementId>,
|
||||||
|
_: Bounds<Pixels>,
|
||||||
|
_: &mut (),
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if let Some(text) = &mut self.text {
|
||||||
|
text.prepaint(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&GlobalElementId>,
|
||||||
|
_: Option<&InspectorElementId>,
|
||||||
|
_: Bounds<Pixels>,
|
||||||
|
_: &mut (),
|
||||||
|
_: &mut (),
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let Some(text) = &mut self.text else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
text.paint(window, cx);
|
||||||
|
let shown = self.shown.borrow();
|
||||||
|
let Some(Shown { layout, .. }) = &*shown else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let line = layout.line_height();
|
||||||
|
for (image, size, drawn) in &self.pictures {
|
||||||
|
let (Some(start), Some(end)) = (
|
||||||
|
layout.position_for_index(drawn.start),
|
||||||
|
layout.position_for_index(drawn.end),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Glue at a line's end ends there.
|
||||||
|
let room = if end.y == start.y {
|
||||||
|
end.x - start.x
|
||||||
|
} else {
|
||||||
|
size.width
|
||||||
|
};
|
||||||
|
let origin = start + point((room - size.width) / 2.0, (line - size.height) / 2.0);
|
||||||
|
let bounds = Bounds {
|
||||||
|
origin,
|
||||||
|
size: *size,
|
||||||
|
};
|
||||||
|
if let Err(error) =
|
||||||
|
window.paint_image(bounds, bounds, Corners::default(), image.clone(), 0, false)
|
||||||
|
{
|
||||||
|
log::error!("markdown: an inline formula did not paint: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_map_both_ways_around_a_formula() {
|
||||||
|
let inline = Inline {
|
||||||
|
text: "a x^2 b".into(),
|
||||||
|
styles: vec![(2..5, Style::Math), (6..7, Style::Strong)],
|
||||||
|
..Inline::default()
|
||||||
|
};
|
||||||
|
let mut swaps = vec![(2..5, 0..0)];
|
||||||
|
let drawn = shown(&inline, &mut swaps, &[4]);
|
||||||
|
assert_eq!(drawn.text, format!("a {} b", GLUE.to_string().repeat(4)));
|
||||||
|
let glue = GLUE.len_utf8() * 4;
|
||||||
|
assert_eq!(swaps[0].1, 2..2 + glue);
|
||||||
|
assert_eq!(
|
||||||
|
drawn.styles[1].0,
|
||||||
|
3 + glue..4 + glue,
|
||||||
|
"the bold b moves past the glue"
|
||||||
|
);
|
||||||
|
assert_eq!(back(&swaps, 3), 2, "glue reads back to its formula");
|
||||||
|
assert_eq!(back(&swaps, 3 + glue), 6);
|
||||||
|
assert_eq!(forward(&swaps, 6), 3 + glue);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Found = Rc<
|
||||||
|
RefCell<
|
||||||
|
Option<(
|
||||||
|
Rc<RefCell<Option<Shown>>>,
|
||||||
|
Box<dyn Fn(Point<Pixels>) -> usize>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
struct Probe(Found);
|
||||||
|
|
||||||
|
impl gpui::Render for Probe {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||||
|
use gpui::{ParentElement as _, Styled as _};
|
||||||
|
let inline = Inline {
|
||||||
|
text: "a x^2 b".into(),
|
||||||
|
styles: vec![(2..5, Style::Math)],
|
||||||
|
..Inline::default()
|
||||||
|
};
|
||||||
|
let (text, at) = MathText::new("probe".into(), inline, None, None);
|
||||||
|
*self.0.borrow_mut() = Some((text.shown.clone(), Box::new(at)));
|
||||||
|
gpui::div().text_size(gpui::px(16.0)).child(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) {
|
||||||
|
cx.update(crate::theme::Theme::init);
|
||||||
|
let found = Found::default();
|
||||||
|
let held = found.clone();
|
||||||
|
let (_, cx) = cx.add_window_view(move |_, _| Probe(held));
|
||||||
|
for _ in 0..4 {
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, _| window.refresh());
|
||||||
|
}
|
||||||
|
let found = found.borrow();
|
||||||
|
let (shown, at) = found.as_ref().expect("drawn");
|
||||||
|
let shown = shown.borrow();
|
||||||
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
|
assert_eq!(swaps.len(), 1, "the formula typeset in place");
|
||||||
|
let drawn = swaps[0].1.clone();
|
||||||
|
let (start, end) = (
|
||||||
|
layout.position_for_index(drawn.start),
|
||||||
|
layout.position_for_index(drawn.end),
|
||||||
|
);
|
||||||
|
let (start, end) = (start.expect("its start"), end.expect("its end"));
|
||||||
|
assert!(
|
||||||
|
end.x - start.x > gpui::px(10.0),
|
||||||
|
"glue holds the picture's width"
|
||||||
|
);
|
||||||
|
let middle = point(
|
||||||
|
(start.x + end.x) / 2.0,
|
||||||
|
start.y + layout.line_height() / 2.0,
|
||||||
|
);
|
||||||
|
assert_eq!(at(middle), 2, "a press on the picture is the formula");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,11 +140,11 @@ impl Draw<'_> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drawn `inline`; a press finds its byte through `layout`.
|
/// Drawn `inline`; a press finds its byte through `at`.
|
||||||
pub(super) fn point_text(
|
pub(super) fn point_text(
|
||||||
&self,
|
&self,
|
||||||
element: AnyElement,
|
element: AnyElement,
|
||||||
layout: TextLayout,
|
at: impl Fn(gpui::Point<gpui::Pixels>) -> usize + 'static,
|
||||||
inline: &Inline,
|
inline: &Inline,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
if self.hooks.on_point.is_none() {
|
if self.hooks.on_point.is_none() {
|
||||||
@@ -152,9 +152,7 @@ impl Draw<'_> {
|
|||||||
}
|
}
|
||||||
let inline = Rc::new(inline.clone());
|
let inline = Rc::new(inline.clone());
|
||||||
self.pointable(element, move |event| {
|
self.pointable(element, move |event| {
|
||||||
let at = layout
|
let at = at(event.position);
|
||||||
.index_for_position(event.position)
|
|
||||||
.unwrap_or_else(|near| near);
|
|
||||||
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
||||||
Pointed {
|
Pointed {
|
||||||
link: link.map(|(_, url)| url.clone()),
|
link: link.map(|(_, url)| url.clone()),
|
||||||
|
|||||||
@@ -318,12 +318,14 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text split at its images; lone `$$` math is block.
|
/// Text split at its images; lone `$$` or `\[` math is block.
|
||||||
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
||||||
if let [mdast::Node::InlineMath(math)] = children
|
if let [mdast::Node::InlineMath(math)] = children
|
||||||
&& self.source[span(node)].starts_with("$$")
|
&& ["$$", "\\["]
|
||||||
|
.iter()
|
||||||
|
.any(|open| self.source[span(node)].starts_with(open))
|
||||||
{
|
{
|
||||||
return out.push(Node::Math(math.value.clone()));
|
return out.push(Node::Math(math.value.trim().to_string()));
|
||||||
}
|
}
|
||||||
let mut run = Run {
|
let mut run = Run {
|
||||||
table: matches!(node, mdast::Node::TableCell(_)),
|
table: matches!(node, mdast::Node::TableCell(_)),
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escape
|
|||||||
Some(match node {
|
Some(match node {
|
||||||
Node::Text(_) => (whole, Escapes::Markdown),
|
Node::Text(_) => (whole, Escapes::Markdown),
|
||||||
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
|
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
|
||||||
Node::InlineMath(_) => (inside(source, whole, '$'), Escapes::Plain),
|
Node::InlineMath(_) => (
|
||||||
|
crate::documents::markdown::math_inside(source, whole),
|
||||||
|
Escapes::Plain,
|
||||||
|
),
|
||||||
_ => (whole, Escapes::Plain),
|
_ => (whole, Escapes::Plain),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -416,3 +416,41 @@ fn the_first_shortcode_decides_its_text_node() {
|
|||||||
assert_eq!(line(source).text, shown, "{source:?}");
|
assert_eq!(line(source).text, shown, "{source:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_delimiters_read_as_math() {
|
||||||
|
assert_eq!(parse(r"\[c = 1\]"), [Node::Math("c = 1".into())]);
|
||||||
|
assert_eq!(parse("\\[\nc = 1\n\\]"), [Node::Math("c = 1".into())]);
|
||||||
|
let Node::Paragraph(inline) = &parse(r"so \(c\) holds")[0] else {
|
||||||
|
panic!("inline math stays in its paragraph")
|
||||||
|
};
|
||||||
|
assert_eq!(inline.text, "so c holds");
|
||||||
|
assert_eq!(inline.styles, [(3..4, Style::Math)]);
|
||||||
|
let Node::Paragraph(alone) = &parse(r"\(c\)")[0] else {
|
||||||
|
panic!("a lone formula stays inline")
|
||||||
|
};
|
||||||
|
assert_eq!(alone.styles, [(0..1, Style::Math)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_delimiters_leave_code_escapes_and_open_pairs() {
|
||||||
|
let text = |source: &str| match &parse(source)[0] {
|
||||||
|
Node::Paragraph(inline) => (inline.text.clone(), inline.styles.clone()),
|
||||||
|
other => panic!("a paragraph, not {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(text(r"`\(c\)`").1, [(0..5, Style::Code)], "code stays code");
|
||||||
|
assert_eq!(
|
||||||
|
text(r"\\(c\\)"),
|
||||||
|
(r"\(c\)".into(), Vec::new()),
|
||||||
|
"escaped backslashes"
|
||||||
|
);
|
||||||
|
assert_eq!(text(r"a \(c").0, "a (c", "no closing, no formula");
|
||||||
|
let split = parse("a \\(c\n\nd\\) e");
|
||||||
|
assert!(
|
||||||
|
split.iter().all(|node| match node {
|
||||||
|
Node::Paragraph(inline) => inline.styles.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
}),
|
||||||
|
"a blank line ends the formula: {split:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-17
@@ -96,7 +96,7 @@ struct Formula {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A typeset formula: its picture and logical size.
|
/// A typeset formula: its picture and logical size.
|
||||||
type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
pub(crate) type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
||||||
|
|
||||||
enum Typesetter {}
|
enum Typesetter {}
|
||||||
|
|
||||||
@@ -201,27 +201,39 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size<Pixels>),
|
|||||||
Ok((reframed, size(px(w), px(h))))
|
Ok((reframed, size(px(w), px(h))))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Latex {
|
/// The asset key for `source` at `font` in `window`.
|
||||||
/// The asset key this formula draws from in `window`.
|
fn formula(
|
||||||
fn formula(&self, window: &Window, cx: &App) -> (Formula, Pixels) {
|
(source, display): (&SharedString, bool),
|
||||||
let theme = cx.theme();
|
font: Pixels,
|
||||||
let font = theme.text_size(self.size).to_pixels(window.rem_size());
|
window: &Window,
|
||||||
let formula = Formula {
|
cx: &App,
|
||||||
source: self.source.clone(),
|
) -> Formula {
|
||||||
display: self.display,
|
Formula {
|
||||||
rgba: u32::from(theme.colors.fg.to_rgb()),
|
source: source.clone(),
|
||||||
font: (font.as_f32() * 100.0).round() as u32,
|
display,
|
||||||
scale: (window.scale_factor() * 100.0).round() as u32,
|
rgba: u32::from(cx.theme().colors.fg.to_rgb()),
|
||||||
};
|
font: (font.as_f32() * 100.0).round() as u32,
|
||||||
(formula, font)
|
scale: (window.scale_factor() * 100.0).round() as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `source` typeset at `font` in `window`; None while it loads.
|
||||||
|
pub(crate) fn typeset(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
(source, display): (&SharedString, bool),
|
||||||
|
font: Pixels,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Option<Typeset> {
|
||||||
|
let formula = formula((source, display), font, window, cx);
|
||||||
|
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
|
||||||
|
hold::<Typesetter>(id, &formula, picture, window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
impl RenderOnce for Latex {
|
impl RenderOnce for Latex {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let (formula, font) = self.formula(window, cx);
|
let font = cx.theme().text_size(self.size).to_pixels(window.rem_size());
|
||||||
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
|
match typeset(self.id, (&self.source, self.display), font, window, cx) {
|
||||||
match hold::<Typesetter>(self.id, &formula, picture, window, cx) {
|
|
||||||
Some(Ok((picture, shape))) => img(picture)
|
Some(Ok((picture, shape))) => img(picture)
|
||||||
.w(shape.width)
|
.w(shape.width)
|
||||||
.h(shape.height)
|
.h(shape.height)
|
||||||
|
|||||||
@@ -114,8 +114,11 @@ fn flush(cx: &mut gpui::VisualTestContext) {
|
|||||||
/// Whether `source`'s picture sits in the asset cache.
|
/// Whether `source`'s picture sits in the asset cache.
|
||||||
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
|
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
let latex = Latex::parse("probe", source).expect("parses");
|
let font = cx
|
||||||
let (formula, _) = latex.formula(window, cx);
|
.theme()
|
||||||
|
.text_size(TextSize::Lg)
|
||||||
|
.to_pixels(window.rem_size());
|
||||||
|
let formula = super::formula((&source.to_string().into(), false), font, window, cx);
|
||||||
cx.has_asset::<Typesetter>(&formula)
|
cx.has_asset::<Typesetter>(&formula)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub use formatted::{
|
|||||||
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
|
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
|
||||||
pub use link::{ExternalLink, Link};
|
pub use link::{ExternalLink, Link};
|
||||||
pub(crate) use math::failed;
|
pub(crate) use math::failed;
|
||||||
|
pub(crate) use math::typeset;
|
||||||
pub use math::{Latex, MathError, formula_svg};
|
pub use math::{Latex, MathError, formula_svg};
|
||||||
pub use rolling::AnimatedNumber;
|
pub use rolling::AnimatedNumber;
|
||||||
pub use select::SelectableText;
|
pub use select::SelectableText;
|
||||||
|
|||||||
Reference in new issue
Block a user