documents: pictures draw in table cells; Markdown-path comments within ten words
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 21s
CI / crate (push) Failing after 11m44s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

A cell holds blocks, split at its pictures as a paragraph is. A picture
takes its natural width, capped by its container.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 05:12:16 -04:00
1 parent bbb607106a
commit 4801555e0d
6 files changed
+84 -43

No files matched your search

+6 -6
View File
@@ -58,7 +58,7 @@ fn leading(text: &str, ch: char) -> usize {
.sum() .sum()
} }
/// How many bytes at each end of `span` write `mark`: its asterisks, tildes or backticks. /// Bytes at each end of `span` that write `mark`.
pub(crate) fn edge(span: &str, mark: Mark) -> usize { pub(crate) fn edge(span: &str, mark: Mark) -> usize {
let first = span.chars().next().expect("a styled span has its marker"); let first = span.chars().next().expect("a styled span has its marker");
let run = leading(span, first).min(span.len() / 2); let run = leading(span, first).min(span.len() / 2);
@@ -76,7 +76,7 @@ pub(crate) fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) {
} }
} }
/// The marks over `text`'s bytes: styles, code, links, headings and quotes, and the syntax that writes each. /// The marks over `text`, with the syntax writing each.
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> { pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
let root = tree(text); let root = tree(text);
let mut nodes = Vec::new(); let mut nodes = Vec::new();
@@ -133,7 +133,7 @@ pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
out out
} }
/// Markdown styled as it is typed: strong and headings heavier, emphasis slanted, strikes struck, code washed, links in their tone; the syntax that writes them recedes. /// Markdown styled as typed; its syntax recedes.
pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> { pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let colors = &cx.theme().colors; let colors = &cx.theme().colors;
let plain = Highlight::new(colors.fg); let plain = Highlight::new(colors.fg);
@@ -174,7 +174,7 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight
.collect() .collect()
} }
/// A link written `[text](address)` around byte `at`: its range, its text and its address. /// The `[text](address)` link around byte `at`, if any.
pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)> { pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)> {
let root = tree(text); let root = tree(text);
let mut nodes = Vec::new(); let mut nodes = Vec::new();
@@ -208,7 +208,7 @@ pub fn block_ranges(text: &str) -> Vec<Range<usize>> {
.collect() .collect()
} }
/// Words in `text` as a reader counts them; each ideograph counts as one. /// Words in `text`; each ideograph counts as one.
pub fn words(text: &str) -> usize { pub fn words(text: &str) -> usize {
text.unicode_words().count() text.unicode_words().count()
} }
@@ -216,7 +216,7 @@ pub fn words(text: &str) -> usize {
/// Words an adult reads in a minute, on average. /// Words an adult reads in a minute, on average.
const PACE: usize = 238; const PACE: usize = 238;
/// Minutes to read `words`, at least one when there are any. /// Minutes to read `words`; at least one if any.
pub fn reading_minutes(words: usize) -> usize { pub fn reading_minutes(words: usize) -> usize {
words.div_ceil(PACE) words.div_ceil(PACE)
} }
+9 -9
View File
@@ -23,7 +23,7 @@ use crate::{
/// How a markdown editor shows its text. /// How a markdown editor shows its text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MarkdownMode { pub enum MarkdownMode {
/// The document as it reads; a pressed block opens as its source. /// The document as read; a pressed block opens as source.
Visual, Visual,
/// The markdown, styled as it is typed. /// The markdown, styled as it is typed.
Source, Source,
@@ -52,7 +52,7 @@ impl MarkdownMode {
type OnMode = Rc<dyn Fn(MarkdownMode, &mut Window, &mut App)>; type OnMode = Rc<dyn Fn(MarkdownMode, &mut Window, &mut App)>;
/// The block open as its source: the document it opened in, where, its own field, and whether it adds a block at the end. /// The open block: its document, range, field, and appending.
struct Open { struct Open {
document: String, document: String,
range: Range<usize>, range: Range<usize>,
@@ -66,7 +66,7 @@ struct Visual {
open: Option<Open>, open: Option<Open>,
} }
/// `range` moved by an edit that wrote `written` bytes over `replaced`, when it lies after it. /// `range` shifted by `written` bytes written over `replaced`.
fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Range<usize> { fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Range<usize> {
if range.start < replaced.end { if range.start < replaced.end {
return range; return range;
@@ -78,7 +78,7 @@ fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Rang
start..end start..end
} }
/// What joins a block added at the end to the text before it. /// What joins an appended block to the text before.
pub(crate) fn joint(document: &str) -> &'static str { pub(crate) fn joint(document: &str) -> &'static str {
if document.is_empty() || document.ends_with("\n\n") { if document.is_empty() || document.ends_with("\n\n") {
"" ""
@@ -89,7 +89,7 @@ pub(crate) fn joint(document: &str) -> &'static str {
} }
} }
/// Writes the open block back over its range as one undo step, and closes it; the range replaced and the bytes written, none when the text left the range. /// Writes the open block back as one step; the edit.
fn commit( fn commit(
state: &Entity<Visual>, state: &Entity<Visual>,
field: &Entity<TextInput>, field: &Entity<TextInput>,
@@ -123,7 +123,7 @@ fn commit(
Some((open.range, written.len())) Some((open.range, written.len()))
} }
/// Opens the block at `range` in a field of its own, so undo stays with it; the caret at its start or end. /// Opens the block at `range` in its own field.
fn open( fn open(
state: &Entity<Visual>, state: &Entity<Visual>,
field: &Entity<TextInput>, field: &Entity<TextInput>,
@@ -168,7 +168,7 @@ fn open(
}); });
} }
/// Whether the caret sits on the field's first line, or with `last` its last. /// The caret on the first line, or with `last` last.
pub(crate) fn at_edge(input: &TextInput, last: bool) -> bool { pub(crate) fn at_edge(input: &TextInput, last: bool) -> bool {
let edge = if last { input.text().len() } else { 0 }; let edge = if last { input.text().len() } else { 0 };
match (input.bounds_for(input.cursor()), input.bounds_for(edge)) { match (input.bounds_for(input.cursor()), input.bounds_for(edge)) {
@@ -177,7 +177,7 @@ pub(crate) fn at_edge(input: &TextInput, last: bool) -> bool {
} }
} }
/// Markdown written three ways: visual, where the document reads as it will and a pressed block opens as its source; source, styled as it is typed; and split, the source beside the document. The toolbar, links, suggestions and counts come with each. /// Markdown written as visual, source, or split views.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct MarkdownEditor { pub struct MarkdownEditor {
id: ElementId, id: ElementId,
@@ -267,7 +267,7 @@ impl RenderOnce for MarkdownEditor {
} }
} }
/// The visual body: each block rendered, the open one as its source. /// The visual body: blocks drawn, the open one as source.
fn visual( fn visual(
editor: MarkdownEditor, editor: MarkdownEditor,
state: Entity<Visual>, state: Entity<Visual>,
+2 -2
View File
@@ -194,8 +194,8 @@ fn picture(shown: Picture, window: &mut Window, cx: &mut App) -> AnyElement {
let width = i32::from(natural.width) as f32 / smooth; let width = i32::from(natural.width) as f32 / smooth;
div() div()
.debug_selector(|| "markdown-picture".into()) .debug_selector(|| "markdown-picture".into())
.w_full() .w(px(width))
.max_w(px(width)) .max_w_full()
.child(img(image).size_full()) .child(img(image).size_full())
.into_any_element() .into_any_element()
} }
+14 -8
View File
@@ -24,7 +24,7 @@ use crate::{
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>; pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>; type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
/// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host. /// Markdown drawn as a document; links go to the host.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct MarkdownRenderer { pub struct MarkdownRenderer {
id: ElementId, id: ElementId,
@@ -61,13 +61,13 @@ impl MarkdownRenderer {
self self
} }
/// Marks each heading for a table of contents over `sections`; `outline` gives its entries. /// Marks headings for `sections`; `outline` lists them.
pub fn sections(mut self, sections: &Entity<Sections>) -> Self { pub fn sections(mut self, sections: &Entity<Sections>) -> Self {
self.sections = Some(sections.clone()); self.sections = Some(sections.clone());
self self
} }
/// Draws code blocks with the owner's element, given an id, the language and the code. /// Code blocks drawn by the owner: id, language, code.
pub fn code( pub fn code(
mut self, mut self,
draw: impl Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement + 'static, draw: impl Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement + 'static,
@@ -83,7 +83,7 @@ impl MarkdownRenderer {
} }
} }
/// Renders nodes with one set of links handlers and ids under one owner. /// Draws nodes under one owner's ids and link handler.
struct Draw<'a> { struct Draw<'a> {
id: &'a ElementId, id: &'a ElementId,
on_link: &'a Option<OnLink>, on_link: &'a Option<OnLink>,
@@ -297,7 +297,7 @@ impl Draw<'_> {
})) }))
.into_any_element(), .into_any_element(),
Node::Table { aligns, head, rows } => { Node::Table { aligns, head, rows } => {
let cell = |inline: &Inline, column: usize, header: bool, cx: &App| { let cell = |blocks: &[Node], column: usize, header: bool, cx: &App| {
div() div()
.flex_1() .flex_1()
.flex() .flex()
@@ -313,7 +313,13 @@ impl Draw<'_> {
cell.font_weight(FontWeight::MEDIUM) cell.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted) .text_color(colors.fg_muted)
}) })
.child(self.inline(inline, cx)) .child(
div()
.flex()
.flex_col()
.gap_1()
.children(self.nodes(blocks, cx)),
)
}; };
div() div()
.flex() .flex()
@@ -326,7 +332,7 @@ impl Draw<'_> {
div().flex().bg(colors.hover).children( div().flex().bg(colors.hover).children(
head.iter() head.iter()
.enumerate() .enumerate()
.map(|(column, inline)| cell(inline, column, true, cx)), .map(|(column, blocks)| cell(blocks, column, true, cx)),
), ),
) )
.children(rows.iter().map(|row| { .children(rows.iter().map(|row| {
@@ -337,7 +343,7 @@ impl Draw<'_> {
.children( .children(
row.iter() row.iter()
.enumerate() .enumerate()
.map(|(column, inline)| cell(inline, column, false, cx)), .map(|(column, blocks)| cell(blocks, column, false, cx)),
) )
})) }))
.into_any_element() .into_any_element()
+25 -9
View File
@@ -317,9 +317,19 @@ impl Render for Page {
} }
} }
/// A 200×100 picture drawn in a `width` column. const SHOT: &str = "Before
fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size<Pixels> {
let folder = std::env::temp_dir().join(format!("ely-picture-{}-{width}", std::process::id())); ![shot](assets/wide%20shot.png)
";
/// A 200×100 picture in `source`, drawn in a `width` column.
fn drawn_picture(source: &'static str, width: f32, cx: &mut TestAppContext) -> gpui::Size<Pixels> {
let named = format!(
"ely-picture-{}-{width}-{}",
std::process::id(),
source.len()
);
let folder = std::env::temp_dir().join(named);
std::fs::create_dir_all(folder.join("assets")).expect("a folder"); std::fs::create_dir_all(folder.join("assets")).expect("a folder");
let mut png = Vec::new(); let mut png = Vec::new();
image::RgbaImage::new(200, 100) image::RgbaImage::new(200, 100)
@@ -328,10 +338,7 @@ fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size<Pixels> {
std::fs::write(folder.join("assets/wide shot.png"), png).expect("the picture"); std::fs::write(folder.join("assets/wide shot.png"), png).expect("the picture");
cx.update(Theme::init); cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Page { let (_, cx) = cx.add_window_view(|_, _| Page {
source: "Before source,
![shot](assets/wide%20shot.png)
",
base: folder.clone(), base: folder.clone(),
width, width,
}); });
@@ -350,13 +357,22 @@ fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size<Pixels> {
#[gpui::test] #[gpui::test]
fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestAppContext) { fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestAppContext) {
let shrunk = drawn_picture(120.0, cx); let shrunk = drawn_picture(SHOT, 120.0, cx);
assert_eq!(shrunk, size(px(120.0), px(60.0)), "shrunk at 2:1"); assert_eq!(shrunk, size(px(120.0), px(60.0)), "shrunk at 2:1");
} }
#[gpui::test] #[gpui::test]
fn a_picture_narrower_than_the_column_keeps_its_size(cx: &mut TestAppContext) { fn a_picture_narrower_than_the_column_keeps_its_size(cx: &mut TestAppContext) {
assert_eq!(drawn_picture(400.0, cx), size(px(200.0), px(100.0))); assert_eq!(drawn_picture(SHOT, 400.0, cx), size(px(200.0), px(100.0)));
}
#[gpui::test]
fn a_picture_in_a_table_cell_draws(cx: &mut TestAppContext) {
let table = "| Picture |
| --- |
| ![shot](assets/wide%20shot.png) |
";
assert_eq!(drawn_picture(table, 400.0, cx), size(px(200.0), px(100.0)));
} }
#[gpui::test] #[gpui::test]
+28 -9
View File
@@ -16,7 +16,7 @@ pub enum Style {
Footnote, Footnote,
} }
/// Inline content: its text, the styled stretches of it, and the address of each link, in order. /// Inline text, its styled stretches and link addresses.
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct Inline { pub struct Inline {
pub text: String, pub text: String,
@@ -34,15 +34,16 @@ pub enum Node {
language: Option<String>, language: Option<String>,
text: String, text: String,
}, },
/// Items with their blocks, numbered from `start` when ordered, each with its checkbox when a task. /// Items and blocks; ordered from `start`; tasks carry checkboxes.
List { List {
start: Option<u64>, start: Option<u64>,
items: Vec<(Option<bool>, Vec<Node>)>, items: Vec<(Option<bool>, Vec<Node>)>,
}, },
/// Each cell's blocks: its text, split at pictures.
Table { Table {
aligns: Vec<AlignKind>, aligns: Vec<AlignKind>,
head: Vec<Inline>, head: Vec<Vec<Node>>,
rows: Vec<Vec<Inline>>, rows: Vec<Vec<Vec<Node>>>,
}, },
Rule, Rule,
Math(String), Math(String),
@@ -174,7 +175,12 @@ impl<'a> Reader<'a> {
mdast::Node::TableRow(row) => row mdast::Node::TableRow(row) => row
.children .children
.iter() .iter()
.map(|cell| self.inline(cell.children().expect("a cell holds inlines"))) .map(|cell| {
let mut blocks = Vec::new();
let inlines = cell.children().expect("a cell holds inlines");
self.paragraph(cell, inlines, &mut blocks);
blocks
})
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
other => unreachable!("a table holds rows, not {other:?}"), other => unreachable!("a table holds rows, not {other:?}"),
}); });
@@ -469,13 +475,26 @@ mod tests {
panic!("a table, not {:?}", nodes[0]) panic!("a table, not {:?}", nodes[0])
}; };
assert_eq!(aligns, &[AlignKind::Left, AlignKind::Right]); assert_eq!(aligns, &[AlignKind::Left, AlignKind::Right]);
let text = |cell: &[Node]| match cell {
[Node::Paragraph(line)] => line.text.clone(),
other => panic!("one line, not {other:?}"),
};
assert_eq!( assert_eq!(
head.iter() head.iter().map(|cell| text(cell)).collect::<Vec<_>>(),
.map(|cell| cell.text.as_str())
.collect::<Vec<_>>(),
["Name", "Lift"] ["Name", "Lift"]
); );
assert_eq!(rows[0][1].text, "0.5"); assert_eq!(text(&rows[0][1]), "0.5");
}
#[test]
fn a_picture_in_a_cell_stays_a_picture() {
let nodes = parse("| Picture |\n| --- |\n| ![shot](assets/shot.png) |\n");
let Node::Table { rows, .. } = &nodes[0] else {
panic!("a table, not {:?}", nodes[0])
};
assert!(
matches!(&rows[0][0][..], [Node::Image { alt, url, .. }] if alt == "shot" && url == "assets/shot.png")
);
} }
#[test] #[test]