From 4801555e0da218ca1424c0905ad2ba8fc9b8aab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 05:12:16 -0400 Subject: [PATCH] documents: pictures draw in table cells; Markdown-path comments within ten words 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) --- src/documents/markdown.rs | 12 ++++++------ src/documents/modes.rs | 18 +++++++++--------- src/documents/picture.rs | 4 ++-- src/documents/render.rs | 22 ++++++++++++++-------- src/documents/tests.rs | 34 +++++++++++++++++++++++++--------- src/documents/tree.rs | 37 ++++++++++++++++++++++++++++--------- 6 files changed, 84 insertions(+), 43 deletions(-) diff --git a/src/documents/markdown.rs b/src/documents/markdown.rs index 2997b1f..650499a 100644 --- a/src/documents/markdown.rs +++ b/src/documents/markdown.rs @@ -58,7 +58,7 @@ fn leading(text: &str, ch: char) -> usize { .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 { let first = span.chars().next().expect("a styled span has its marker"); 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, Mark)> { let root = tree(text); let mut nodes = Vec::new(); @@ -133,7 +133,7 @@ pub fn marks(text: &str) -> Vec<(Range, Mark)> { 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, Highlight)> { let colors = &cx.theme().colors; let plain = Highlight::new(colors.fg); @@ -174,7 +174,7 @@ pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range, Highlight .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, String, String)> { let root = tree(text); let mut nodes = Vec::new(); @@ -208,7 +208,7 @@ pub fn block_ranges(text: &str) -> Vec> { .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 { text.unicode_words().count() } @@ -216,7 +216,7 @@ pub fn words(text: &str) -> usize { /// Words an adult reads in a minute, on average. 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 { words.div_ceil(PACE) } diff --git a/src/documents/modes.rs b/src/documents/modes.rs index 5c6db2a..5947f8f 100644 --- a/src/documents/modes.rs +++ b/src/documents/modes.rs @@ -23,7 +23,7 @@ use crate::{ /// How a markdown editor shows its text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] 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, /// The markdown, styled as it is typed. Source, @@ -52,7 +52,7 @@ impl MarkdownMode { type OnMode = Rc; -/// 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 { document: String, range: Range, @@ -66,7 +66,7 @@ struct Visual { open: Option, } -/// `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, replaced: &Range, written: usize) -> Range { if range.start < replaced.end { return range; @@ -78,7 +78,7 @@ fn shifted(range: Range, replaced: &Range, written: usize) -> Rang 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 { 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( state: &Entity, field: &Entity, @@ -123,7 +123,7 @@ fn commit( 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( state: &Entity, field: &Entity, @@ -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 { let edge = if last { input.text().len() } else { 0 }; 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)] pub struct MarkdownEditor { 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( editor: MarkdownEditor, state: Entity, diff --git a/src/documents/picture.rs b/src/documents/picture.rs index f4094b2..0089e78 100644 --- a/src/documents/picture.rs +++ b/src/documents/picture.rs @@ -194,8 +194,8 @@ fn picture(shown: Picture, window: &mut Window, cx: &mut App) -> AnyElement { let width = i32::from(natural.width) as f32 / smooth; div() .debug_selector(|| "markdown-picture".into()) - .w_full() - .max_w(px(width)) + .w(px(width)) + .max_w_full() .child(img(image).size_full()) .into_any_element() } diff --git a/src/documents/render.rs b/src/documents/render.rs index 1bf651f..6f8932e 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -24,7 +24,7 @@ use crate::{ pub(crate) type OnLink = Rc; type DrawCode = Rc, 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)] pub struct MarkdownRenderer { id: ElementId, @@ -61,13 +61,13 @@ impl MarkdownRenderer { 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) -> Self { self.sections = Some(sections.clone()); 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( mut self, draw: impl Fn(ElementId, Option, 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> { id: &'a ElementId, on_link: &'a Option, @@ -297,7 +297,7 @@ impl Draw<'_> { })) .into_any_element(), 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() .flex_1() .flex() @@ -313,7 +313,13 @@ impl Draw<'_> { cell.font_weight(FontWeight::MEDIUM) .text_color(colors.fg_muted) }) - .child(self.inline(inline, cx)) + .child( + div() + .flex() + .flex_col() + .gap_1() + .children(self.nodes(blocks, cx)), + ) }; div() .flex() @@ -326,7 +332,7 @@ impl Draw<'_> { div().flex().bg(colors.hover).children( head.iter() .enumerate() - .map(|(column, inline)| cell(inline, column, true, cx)), + .map(|(column, blocks)| cell(blocks, column, true, cx)), ), ) .children(rows.iter().map(|row| { @@ -337,7 +343,7 @@ impl Draw<'_> { .children( row.iter() .enumerate() - .map(|(column, inline)| cell(inline, column, false, cx)), + .map(|(column, blocks)| cell(blocks, column, false, cx)), ) })) .into_any_element() diff --git a/src/documents/tests.rs b/src/documents/tests.rs index e6fc9a8..6cf67cf 100644 --- a/src/documents/tests.rs +++ b/src/documents/tests.rs @@ -317,9 +317,19 @@ impl Render for Page { } } -/// A 200×100 picture drawn in a `width` column. -fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size { - let folder = std::env::temp_dir().join(format!("ely-picture-{}-{width}", std::process::id())); +const SHOT: &str = "Before + +![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 { + 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"); let mut png = Vec::new(); image::RgbaImage::new(200, 100) @@ -328,10 +338,7 @@ fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size { std::fs::write(folder.join("assets/wide shot.png"), png).expect("the picture"); cx.update(Theme::init); let (_, cx) = cx.add_window_view(|_, _| Page { - source: "Before - -![shot](assets/wide%20shot.png) -", + source, base: folder.clone(), width, }); @@ -350,13 +357,22 @@ fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size { #[gpui::test] 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"); } #[gpui::test] 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] diff --git a/src/documents/tree.rs b/src/documents/tree.rs index f5d5051..69f173b 100644 --- a/src/documents/tree.rs +++ b/src/documents/tree.rs @@ -16,7 +16,7 @@ pub enum Style { 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)] pub struct Inline { pub text: String, @@ -34,15 +34,16 @@ pub enum Node { language: Option, 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 { start: Option, items: Vec<(Option, Vec)>, }, + /// Each cell's blocks: its text, split at pictures. Table { aligns: Vec, - head: Vec, - rows: Vec>, + head: Vec>, + rows: Vec>>, }, Rule, Math(String), @@ -174,7 +175,12 @@ impl<'a> Reader<'a> { mdast::Node::TableRow(row) => row .children .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::>(), other => unreachable!("a table holds rows, not {other:?}"), }); @@ -469,13 +475,26 @@ mod tests { panic!("a table, not {:?}", nodes[0]) }; 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!( - head.iter() - .map(|cell| cell.text.as_str()) - .collect::>(), + head.iter().map(|cell| text(cell)).collect::>(), ["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]