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 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.
|
||||
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(_) => escapes(text, whole),
|
||||
Node::InlineMath(_) => {
|
||||
let inner = inside(text, whole.clone(), '$');
|
||||
let inner = math_inside(text, whole.clone());
|
||||
vec![whole.start..inner.start, inner.end..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 markdown::{Constructs, ParseOptions, mdast};
|
||||
@@ -20,19 +20,92 @@ pub(crate) fn options() -> ParseOptions {
|
||||
|
||||
/// `text` as a syntax tree.
|
||||
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.
|
||||
pub fn file_options() -> ParseOptions {
|
||||
pub(crate) fn file_options() -> ParseOptions {
|
||||
let mut options = options();
|
||||
options.constructs.frontmatter = true;
|
||||
options
|
||||
}
|
||||
|
||||
/// A whole file as a syntax tree.
|
||||
pub(crate) fn file_tree(text: &str) -> mdast::Node {
|
||||
markdown::to_mdast(text, &file_options()).expect("markdown without MDX always parses")
|
||||
pub fn file_tree(text: &str) -> mdast::Node {
|
||||
parsed(text, &file_options())
|
||||
}
|
||||
|
||||
/// 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 knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
|
||||
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 modes::{MarkdownEditor, MarkdownMode};
|
||||
pub use pages::{PageCover, PageIcon};
|
||||
|
||||
+48
-25
@@ -3,7 +3,7 @@ use std::{path::Path, rc::Rc, sync::Arc};
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StrikethroughStyle, Styled,
|
||||
StyledText, UnderlineStyle, Window, div, prelude::*, relative,
|
||||
StyledText, TextLayout, UnderlineStyle, Window, div, prelude::*, relative,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -21,6 +21,7 @@ use crate::{
|
||||
};
|
||||
|
||||
mod blocks;
|
||||
mod math_text;
|
||||
mod point;
|
||||
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.
|
||||
struct Draw<'a> {
|
||||
id: &'a ElementId,
|
||||
@@ -198,30 +231,20 @@ impl Draw<'_> {
|
||||
}
|
||||
|
||||
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rule = theme.underline_thickness();
|
||||
let styles: Vec<_> = inline
|
||||
.styles
|
||||
.iter()
|
||||
.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 inline.links.is_empty() {
|
||||
true => styled.into_any_element(),
|
||||
false => {
|
||||
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)
|
||||
let links = (!inline.links.is_empty()).then(|| self.key("links"));
|
||||
if !inline.styles.iter().any(|(_, style)| *style == Style::Math) {
|
||||
let (drawn, layout) = drawn_text(inline, &[], links, self.hooks.on_link, cx);
|
||||
let at = move |position| {
|
||||
layout
|
||||
.index_for_position(position)
|
||||
.unwrap_or_else(|near| near)
|
||||
};
|
||||
return self.point_text(drawn, at, inline);
|
||||
}
|
||||
let on_link = self.hooks.on_link.clone();
|
||||
let (drawn, at) =
|
||||
math_text::MathText::new(self.key("math"), inline.clone(), links, on_link);
|
||||
self.point_text(drawn.into_any_element(), at, inline)
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
layout: TextLayout,
|
||||
at: impl Fn(gpui::Point<gpui::Pixels>) -> usize + 'static,
|
||||
inline: &Inline,
|
||||
) -> AnyElement {
|
||||
if self.hooks.on_point.is_none() {
|
||||
@@ -152,9 +152,7 @@ impl Draw<'_> {
|
||||
}
|
||||
let inline = Rc::new(inline.clone());
|
||||
self.pointable(element, move |event| {
|
||||
let at = layout
|
||||
.index_for_position(event.position)
|
||||
.unwrap_or_else(|near| near);
|
||||
let at = at(event.position);
|
||||
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
||||
Pointed {
|
||||
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>) {
|
||||
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 {
|
||||
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 {
|
||||
Node::Text(_) => (whole, Escapes::Markdown),
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -416,3 +416,41 @@ fn the_first_shortcode_decides_its_text_node() {
|
||||
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.
|
||||
type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
||||
pub(crate) type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
||||
|
||||
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))))
|
||||
}
|
||||
|
||||
impl Latex {
|
||||
/// The asset key this formula draws from in `window`.
|
||||
fn formula(&self, window: &Window, cx: &App) -> (Formula, Pixels) {
|
||||
let theme = cx.theme();
|
||||
let font = theme.text_size(self.size).to_pixels(window.rem_size());
|
||||
let formula = Formula {
|
||||
source: self.source.clone(),
|
||||
display: self.display,
|
||||
rgba: u32::from(theme.colors.fg.to_rgb()),
|
||||
font: (font.as_f32() * 100.0).round() as u32,
|
||||
scale: (window.scale_factor() * 100.0).round() as u32,
|
||||
};
|
||||
(formula, font)
|
||||
/// The asset key for `source` at `font` in `window`.
|
||||
fn formula(
|
||||
(source, display): (&SharedString, bool),
|
||||
font: Pixels,
|
||||
window: &Window,
|
||||
cx: &App,
|
||||
) -> Formula {
|
||||
Formula {
|
||||
source: source.clone(),
|
||||
display,
|
||||
rgba: u32::from(cx.theme().colors.fg.to_rgb()),
|
||||
font: (font.as_f32() * 100.0).round() as u32,
|
||||
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 {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (formula, font) = self.formula(window, cx);
|
||||
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
|
||||
match hold::<Typesetter>(self.id, &formula, picture, window, cx) {
|
||||
let font = cx.theme().text_size(self.size).to_pixels(window.rem_size());
|
||||
match typeset(self.id, (&self.source, self.display), font, window, cx) {
|
||||
Some(Ok((picture, shape))) => img(picture)
|
||||
.w(shape.width)
|
||||
.h(shape.height)
|
||||
|
||||
@@ -114,8 +114,11 @@ fn flush(cx: &mut gpui::VisualTestContext) {
|
||||
/// Whether `source`'s picture sits in the asset cache.
|
||||
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
|
||||
cx.update(|window, cx| {
|
||||
let latex = Latex::parse("probe", source).expect("parses");
|
||||
let (formula, _) = latex.formula(window, cx);
|
||||
let font = 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use formatted::{
|
||||
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
|
||||
pub use link::{ExternalLink, Link};
|
||||
pub(crate) use math::failed;
|
||||
pub(crate) use math::typeset;
|
||||
pub use math::{Latex, MathError, formula_svg};
|
||||
pub use rolling::AnimatedNumber;
|
||||
pub use select::SelectableText;
|
||||
|
||||
Reference in new issue
Block a user