use std::{ cell::Cell, collections::HashMap, mem, ops::Range, path::{Path, PathBuf}, }; use anyhow::{Result, anyhow}; use galaxy_core::{features::FeatureFlag, ui::theme::Fill as ThemeFill}; use galaxyui::{ AppContext, assets::asset_cache::AssetSource, fonts::Weight, text::point::Point, text_layout::{StyleAndFont, TextAlignment}, units::{IntoPixels, Pixels}, }; use itertools::Itertools; use markdown_parser::{Hyperlink, TableAlignment}; use num_traits::SaturatingSub; use rangemap::RangeSet; use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator}; use urlocator::{UrlLocation, UrlLocator}; use vec1::Vec1; use crate::{ parallel_util::Last, render::{ TABLE_BASELINE_RATIO, TABLE_LINE_HEIGHT_RATIO, layout::{InlineTextLayoutInput, TextLayout, add_link_to_style_and_font}, model::{ BlockItem, BlockLocation, BlockSpacing, CellLayout, Cursor, Decoration, FrameOffset, HiddenBlockConfig, HorizontalRuleConfig, ImageBlockConfig, LaidOutEmbeddedItem, LaidOutTable, LineCount, OffsetMap, Paragraph, ParagraphBlock, ParagraphStyles, RenderLayoutOptions, SelectableTextRun, TableBlockConfig, TableStyle, gutter_expansion_button_types, }, }, }; use galaxyui::text::char_slice; use string_offset::{ByteOffset, CharOffset}; use super::{ buffer::{StyledBufferBlock, StyledBufferRun, StyledTextBlock}, mermaid_diagram::mermaid_diagram_layout, text::{BufferBlockItem, BufferBlockStyle, CodeBlockType, FormattedTable, TableBlockCache}, }; #[cfg(test)] #[path = "edit_tests.rs"] mod tests; /// Resolve an image source path to an AssetSource. /// /// Supports the following markdown image formats per the CommonMark spec: /// https://spec.commonmark.org/0.31.2/#images /// - URLs: `http://` or `https://` prefixed paths /// - Absolute paths: paths starting with `/` /// - Relative paths: all other paths, resolved relative to the document location /// /// Note: Path canonicalization is not available on WASM targets. #[cfg(not(target_arch = "wasm32"))] pub fn resolve_asset_source_relative_to_directory( source: &str, base_directory: Option<&Path>, ) -> AssetSource { if source.starts_with("http://") || source.starts_with("https://") { asset_cache::url_source(source) } else if source.starts_with("/") { AssetSource::LocalFile { path: source.to_string(), } } else { let resolved_path = if let Some(base_directory) = base_directory { base_directory.join(source) } else { Path::new(source).to_path_buf() }; AssetSource::LocalFile { path: match resolved_path.canonicalize() { Ok(canon) => canon.to_string_lossy().to_string(), Err(_) => resolved_path.to_string_lossy().to_string(), }, } } } fn resolve_asset_source(source: &str, base_path: Option<&Path>) -> AssetSource { let base_directory = base_path.map(|base| base.parent().unwrap_or(base)); resolve_asset_source_relative_to_directory(source, base_directory) } #[cfg(target_arch = "wasm32")] pub fn resolve_asset_source_relative_to_directory( source: &str, _base_directory: Option<&Path>, ) -> AssetSource { if source.starts_with("http://") || source.starts_with("https://") { asset_cache::url_source(source) } else { AssetSource::LocalFile { path: source.to_string(), } } } /// Default height multiplier for images when no dimensions are specified. /// Images are rendered at 10x the base line height by default. const DEFAULT_IMAGE_HEIGHT_LINE_MULTIPLIER: f32 = 10.0; const MIN_TABLE_CELL_CONTENT_WIDTH_EMS: f32 = 1.0; const MAX_TABLE_CELL_CONTENT_WIDTH_PX: f32 = 500.0; /// Metadata for rendering a temporary block in the render model. Temporary blocks are not selectable or editable. #[derive(Debug, Clone, PartialEq)] pub struct TemporaryBlock { pub content: String, pub insert_before: LineCount, pub line_decoration: Option, pub inline_text_decorations: Vec, } /// The exact replacement range of an edit. /// Notice there are three possible prefix here, each with different semantics: /// * `replaced`: The old content state before the edit /// * `new`: The new content state right after the edit /// * `resolved`: The final content state after all edits are applied (note this will be different from /// `new` if there are other edits after this one) #[derive(Clone, Debug, PartialEq, Eq)] pub struct PreciseDelta { /// The old content range that was replaced (before this edit). pub replaced_range: Range, /// The old point range that was replaced (before this edit). pub replaced_points: Range, /// 1-indexed byte range of the old content that was replaced (before this edit). pub replaced_byte_range: Range, /// Byte length of the newly inserted content (right after this edit). pub new_byte_length: usize, /// Point at the end of the newly inserted content (right after this edit, 1-indexed). pub new_end_point: Point, /// The range of the newly inserted content in the **final** buffer's coordinate system. /// For multi-delta edits, this is resolved via anchors after all edits are applied, /// ensuring it correctly reflects any shifts caused by subsequent edits. pub resolved_range: Range, } impl PreciseDelta { /// The length of the resolved content range in the final buffer. pub fn resolved_length(&self) -> CharOffset { self.resolved_range .end .saturating_sub(&self.resolved_range.start) } } /// Delta after an edit operation recording the old range of rows that got replaced /// and the content of new rows changed after the edit. This is necessary for the rendering /// model to know what block objects need a re-layout. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct EditDelta { /// The exact replacement charoffset range where content was changed. pub precise_deltas: Vec, /// Offset of the old blocks that are being replaced. The start is the first /// character of the first block, and the end is the end of the last block - /// the first character after it. pub old_offset: Range, /// Content of the lines that have been changed. pub new_lines: Vec, } /// Render Delta that has its content laid out into TextFrames. #[derive(Debug)] pub struct LaidOutRenderDelta { /// Offset of the old blocks that are being replaced. The start is the first /// character of the first block, and the end is the end of the last block - /// the first character after it. pub old_offset: Range, pub laid_out_line: Vec, /// There is a trailing newline within this edit delta. This is used to /// render a placeholder for cursors that are at the end of the buffer and on a newline. pub trailing_newline: Option, } /// A detected URL from the updated text frame. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParsedUrl { /// The range of the URL offsetted from the beginning of the block. /// Note that CharOffset is not used here as this is 1:1 mapped to the character length /// at the text layout stage and not the internal buffer (we could have placeholder text). url_range: Range, link: String, } impl ParsedUrl { pub(crate) fn new(url_range: Range, link: String) -> Self { Self { url_range, link } } pub fn url_range(&self) -> Range { self.url_range.clone() } pub fn link(&self) -> String { self.link.clone() } } // Utility struct for necessary arguments to lay out a TextFrame. struct LayOutArgs { /// Visible characters in the current paragraph, including content text and placeholders. text: String, /// Style decorating each content fragment. style_runs: Vec<(Range, StyleAndFont)>, /// Total length of buffer-content in the run, not necessarily the same as the length of `text`. content_length: CharOffset, /// The "current" interactive run. We keep adding styled runs onto this until we find a /// placeholder. At that point, we end the current run and start a new one after the placeholder. current_interactive_run: SelectableTextRun, /// Accumulator for runs of interactive (non-placeholder) text within the paragraph. selectable_runs: Vec, /// List of detected urls. highlighted_urls: Vec, /// Offset of the current text frame from the start of the block. This lets us handle URLs /// in multi-line blocks. frame_offset_from_block_start: usize, /// The next url that has not been fully laid out yet. next_url_index: usize, /// The active urls in the current text line. active_line_url: Vec, } struct TableCellTextLayout { paragraph_style: ParagraphStyles, text_layout: InlineTextLayoutInput, } /// The first table layout pass measures each cell without a column-width constraint so we can /// compute shared column widths before the final pass applies alignment-sensitive layout. We also /// keep the styled text inputs from this pass so the second pass can reuse them directly. fn measure_table_cells( table: &FormattedTable, layout: &TextLayout, table_style: &TableStyle, header_paragraph_style: ParagraphStyles, body_paragraph_style: ParagraphStyles, ) -> (Vec, Vec>) { let num_cols = table.headers.len(); let mut column_widths = vec![Pixels::zero(); num_cols]; let mut cell_text_layouts = Vec::with_capacity(1 + table.rows.len()); for (row_idx, row) in std::iter::once(&table.headers) .chain(table.rows.iter()) .enumerate() { let paragraph_style = if row_idx == 0 { header_paragraph_style } else { body_paragraph_style }; let mut row_text_layouts = Vec::with_capacity(row.len()); for (col_idx, cell_inline) in row.iter().enumerate() { let runs: Vec = cell_inline .iter() .map(|fragment| StyledBufferRun { run: fragment.text.clone(), text_styles: fragment.styles.clone().into(), block_style: BufferBlockStyle::PlainText, }) .collect(); let mut line = LayOutArgs::new(); line.highlighted_urls = highlight_urls(&runs); for run in &runs { line.layout_run(layout, run, ¶graph_style); } let text_layout = InlineTextLayoutInput { text: line.text, style_runs: line.style_runs, }; let frame = layout.layout_text_with_options( &text_layout.text, ¶graph_style, &text_layout.style_runs, f32::MAX, TextAlignment::Left, ); let cell_width = (frame.max_width() + table_style.cell_padding * 2.0) .into_pixels() .max(minimum_table_cell_width(table_style)) .min(maximum_table_cell_width(table_style)); if let Some(w) = column_widths.get_mut(col_idx) { *w = (*w).max(cell_width); } row_text_layouts.push(TableCellTextLayout { paragraph_style, text_layout, }); } cell_text_layouts.push(row_text_layouts); } (column_widths, cell_text_layouts) } fn minimum_table_cell_width(table_style: &TableStyle) -> Pixels { (table_style.cell_padding * 2.0 + table_style.font_size * MIN_TABLE_CELL_CONTENT_WIDTH_EMS) .into_pixels() } fn maximum_table_cell_width(table_style: &TableStyle) -> Pixels { (table_style.cell_padding * 2.0 + MAX_TABLE_CELL_CONTENT_WIDTH_PX).into_pixels() } impl LayOutArgs { fn new() -> LayOutArgs { Self { text: String::new(), style_runs: vec![], content_length: CharOffset::zero(), current_interactive_run: SelectableTextRun { content_start: CharOffset::zero(), frame_start: FrameOffset::zero(), length: 0, }, selectable_runs: vec![], highlighted_urls: vec![], frame_offset_from_block_start: 0, next_url_index: 0, active_line_url: vec![], } } /// Reset LayOutArgs for a new text line. Note that states /// tracked across lines are not reset. fn reset_for_newline(&mut self) { self.text = String::new(); self.style_runs = vec![]; self.content_length = CharOffset::zero(); self.current_interactive_run = SelectableTextRun { content_start: CharOffset::zero(), frame_start: FrameOffset::zero(), length: 0, }; self.selectable_runs = vec![]; self.active_line_url = vec![]; } fn is_empty(&self) -> bool { self.text.is_empty() } /// Builds the [`OffsetMap`] from the accumulated [`SelectableTextRun`]s. fn finish_offsets(&mut self) -> OffsetMap { // If the last styled buffer run was a placeholder, then the current interactive run will // be empty. Otherwise, we need to add the current run onto the accumulator before // creating the OffsetMap. if self.current_interactive_run.length > 0 { self.selectable_runs .push(self.current_interactive_run.clone()); } OffsetMap::new(mem::take(&mut self.selectable_runs)) } fn layout_run( &mut self, layout: &TextLayout, content: &StyledBufferRun, paragraph_styles: &ParagraphStyles, ) -> bool { let (run_content, has_new_line) = if let Some(content) = content.run.strip_suffix('\n') { (content, true) } else { (content.run.as_str(), false) }; let offset_from_frame_start = self.text.chars().count(); // Update the active line style run. let char_length = run_content.chars().count(); // Base style passed from the buffer model. let base_style_and_font = layout.style_and_font(paragraph_styles, &content.text_styles); let mut styling_start = offset_from_frame_start; while let Some(url) = self.highlighted_urls.get(self.next_url_index) { let index_range = &url.url_range; let Some(url_start) = index_range .start .checked_sub(self.frame_offset_from_block_start) else { // Autodetected URLs cannot cross frame boundaries. log::error!( "URL starts at {} but frame starts at {}", index_range.start, self.frame_offset_from_block_start ); continue; }; let url_end = index_range.end - self.frame_offset_from_block_start; // If url does not overlap with the current run, break and render the run in base style. if url_start >= offset_from_frame_start + char_length { break; } // Push fragments before the url. if url_start > styling_start { self.style_runs .push((styling_start..url_start, base_style_and_font)); styling_start = url_start; } let end = url_end .min(offset_from_frame_start + char_length) .max(styling_start); let style_and_font = add_link_to_style_and_font(base_style_and_font); self.style_runs.push((styling_start..end, style_and_font)); styling_start = end; if end >= url_end { // If end is after the url_end, this means we have fully laid out the url and could // advance to the next one. self.active_line_url.push(ParsedUrl { url_range: url_start..url_end, link: url.link.clone(), }); self.next_url_index += 1; } else { // Otherwise, the URL continues past this run, so we should lay out the rest of // the URL as part of the next run. There will be no more auto-detected URLs // that overlap with this run. break; } } if styling_start < offset_from_frame_start + char_length { self.style_runs.push(( styling_start..offset_from_frame_start + char_length, base_style_and_font, )) } // Update the active line content. self.text.push_str(run_content); if content.text_styles.is_placeholder() { // Placeholders count as 1 content character. self.content_length += 1; // Push the previous interactive run, making the new current interactive run start // just _after_ the placeholder. let previous_run = mem::replace( &mut self.current_interactive_run, SelectableTextRun { // We updated content_length above, so this is the offset just after the placeholder. content_start: self.content_length, // This will be the text frame character right after the last character in the // placeholder. frame_start: FrameOffset::from(offset_from_frame_start + char_length), length: 0, }, ); self.selectable_runs.push(previous_run); } else { self.content_length += char_length; self.current_interactive_run.length += char_length; } if has_new_line { // frame_offset_from_block_start is in terms of displayed characters, not content // characters, so we use the length of self.text instead of self.content_length // (the calculation below is equal to the number of characters in self.text) self.frame_offset_from_block_start += offset_from_frame_start + char_length; self.content_length += 1; self.current_interactive_run.length += 1; } has_new_line } } impl EditDelta { /// Lay out the given EditDelta into TextFrames. /// If hidden_lines is provided, lines within hidden ranges will be laid out as BlockItem::Hidden. pub fn layout_delta( self, layout: &TextLayout, document_path: Option<&Path>, layout_options: RenderLayoutOptions, hidden_ranges: Option>, app: &AppContext, ) -> LaidOutRenderDelta { let hidden_ranges = hidden_ranges.unwrap_or_default(); // old_offset is in the same 1-indexed coordinate system as hidden ranges. let mut current_offset = (self.old_offset.start).max(CharOffset::from(1)); // First, build a Vec of layout tasks with information about whether they're hidden let layout_tasks: Vec<_> = self .new_lines .into_iter() .filter_map(|block| { let content_length = block.content_length(); if content_length == CharOffset::zero() { None } else { let task = LayoutTask::from_styled_block( block, layout, layout_options, app, document_path, ); let is_hidden = hidden_ranges.contains(¤t_offset); current_offset += content_length; Some((task, is_hidden)) } }) .collect(); let last_task = layout_tasks.len().saturating_sub(1); // Then, run each task in parallel, collecting (a) the laid out BlockItems and (b) whether // or not the last item ends with a newline. let (block_items, has_trailing_newline): (Vec<_>, Last<_>) = layout_tasks .into_par_iter() .enumerate() .filter_map(|(idx, (task, is_hidden))| { let location = if idx == 0 { BlockLocation::Start } else if idx >= last_task { BlockLocation::End } else { BlockLocation::Middle }; match task.run(layout, location, is_hidden) { Ok(result) => Some(result), Err(e) => { log::error!( "Failed to lay out BlockItem at offset {:?}: {:?}", self.old_offset, e ); None } } }) .unzip(); // Iterate through block_items, and collapse adjacent Hidden items. let block_items = block_items.into_iter().fold(Vec::new(), |mut acc, item| { if let Some(last) = acc.last_mut() { // If the last item is Hidden and the current item is also Hidden, // we can skip adding the current item. if let (BlockItem::Hidden(running_config), BlockItem::Hidden(config)) = (last, &item) { *running_config += *config; return acc; } } acc.push(item); acc }); // Trailing newline is default to true. This default value is used when // edit delta has no new line, which means one or multiple entire lines have // been deleted. We should still leave a trailing newline in this case. let has_trailing_newline = has_trailing_newline.into_inner().unwrap_or(true); let rich_text_styles = layout.rich_text_styles(); LaidOutRenderDelta { old_offset: self.old_offset.clone(), laid_out_line: block_items, trailing_newline: has_trailing_newline.then(|| { Cursor::new( rich_text_styles.base_line_height(), rich_text_styles.cursor_width.into_pixels(), rich_text_styles .block_spacings .from_block_style(&BufferBlockStyle::PlainText), rich_text_styles.minimum_paragraph_height, ) }), } } } /// Lay out a list of temporary blocks in parallel. pub fn layout_temporary_blocks( blocks: Vec, layout: &TextLayout, ) -> HashMap> { let layout_tasks = blocks .into_iter() .map(|block| { ( LayoutTask::temporary_block( block.content, block.line_decoration, block.inline_text_decorations, ), block.insert_before, ) }) .collect_vec(); let last_task = layout_tasks.len().saturating_sub(1); let results: Vec<_> = layout_tasks .into_par_iter() .enumerate() .filter_map(|(idx, (task, line_count))| { let location = if idx == 0 { BlockLocation::Start } else if idx >= last_task { BlockLocation::End } else { BlockLocation::Middle }; match task.run(layout, location, false) { Ok(result) => Some((line_count, result.0)), Err(e) => { log::error!("Failed to lay out temporary blocks: {e:?}"); None } } }) .collect(); results.into_iter().into_group_map() } /// A unit of work for parallel layout of an edit. enum LayoutTask { /// An embedded item, which is laid out on the main thread so that it can access /// [`AppContext`]. Embed(Box), /// A text block, which will be laid out in parallel. Text(StyledTextBlock), MermaidDiagram { text_block: StyledTextBlock, asset_source: AssetSource, config: ImageBlockConfig, }, /// A horizontal rule, which requires no layout. HorizontalRule(HorizontalRuleConfig), /// An image, which requires no layout. Image { alt_text: String, source: String, config: ImageBlockConfig, document_path: Option, }, /// A temporary text block. TemporaryBlock { content: String, line_decoration: Option, decoration: Vec, }, } impl LayoutTask { /// Convert a block of styled content to the possibly-parallelizable layout work it requires. fn from_styled_block( content: StyledBufferBlock, layout: &TextLayout, layout_options: RenderLayoutOptions, app: &AppContext, document_path: Option<&Path>, ) -> Self { match content { StyledBufferBlock::Item(item) => match item { // To lay out a horizontal rule, we only need the viewport dimensions. BufferBlockItem::HorizontalRule => { let spacing = layout .rich_text_styles() .block_spacings .from_block_style(&BufferBlockStyle::PlainText); Self::HorizontalRule(HorizontalRuleConfig { line_height: layout.rich_text_styles().base_line_height(), width: layout.max_width() - spacing.x_axis_offset(), spacing, }) } BufferBlockItem::Image { alt_text, source, // Title is preserved on BufferBlockItem for round-trip and // HTML export; the editor render currently doesn't surface it. title: _, } => { let spacing = layout .rich_text_styles() .block_spacings .from_block_style(&BufferBlockStyle::PlainText); // Default size for images - will scale based on actual image dimensions let max_width = layout.max_width() - spacing.x_axis_offset(); let default_height = layout.rich_text_styles().base_line_height() * DEFAULT_IMAGE_HEIGHT_LINE_MULTIPLIER.into_pixels(); Self::Image { alt_text: alt_text.clone(), source: source.clone(), config: ImageBlockConfig { width: max_width, height: default_height, spacing, }, document_path: document_path.map(|p| p.to_path_buf()), } } BufferBlockItem::Embedded { item } => { // Lay out the embedded object synchronously. // TODO: We _could_ adapt the embed API to support parallel layout, but it's // likely not worth the effort. Self::Embed(item.layout(layout, app)) } }, StyledBufferBlock::Text(text_block) => { if layout_options.render_mermaid_diagrams && matches!( text_block.style, BufferBlockStyle::CodeBlock { code_block_type: CodeBlockType::Mermaid, } ) { let source = text_block .block .iter() .map(|run| run.run.as_str()) .collect::(); let spacing = layout .rich_text_styles() .block_spacings .from_block_style(&text_block.style); let (asset_source, config) = mermaid_diagram_layout(&source, layout, spacing, app); Self::MermaidDiagram { text_block, asset_source, config, } } else { Self::Text(text_block) } } } } fn temporary_block( content: String, line_decoration: Option, decoration: Vec, ) -> Self { Self::TemporaryBlock { content, line_decoration, decoration, } } /// Run this task, returning the laid-out block item and whether or not it has a trailing /// newline. fn run( self, layout: &TextLayout, location: BlockLocation, is_hidden: bool, ) -> Result<(BlockItem, bool)> { match self { Self::Embed(item) => Ok((BlockItem::Embedded(item.into()), true)), Self::HorizontalRule(config) => Ok((BlockItem::HorizontalRule(config), true)), Self::Image { alt_text, source, config, document_path, } => { let asset_source = resolve_asset_source(&source, document_path.as_deref()); Ok(( BlockItem::Image { alt_text, source, asset_source, config, }, true, // Images are always followed by a trailing newline in the buffer )) } Self::Text(text_block) => layout_text_block(text_block, layout, location, is_hidden), Self::MermaidDiagram { text_block, asset_source, config, } => { layout_mermaid_diagram_block(text_block, asset_source, config, location, is_hidden) } Self::TemporaryBlock { content, line_decoration, decoration, } => Ok(( BlockItem::TemporaryBlock { paragraph_block: layout_temporary_block(content, layout), text_decoration: decoration, decoration: line_decoration, }, true, )), } } } /// Estimate the number of paragraphs (logical lines) in a text block. /// For code blocks, use the number of runs as an overestimate. /// For non-code blocks, assume a single paragraph. fn estimate_paragraph_count(text_block: &StyledTextBlock) -> usize { if matches!(text_block.style, BufferBlockStyle::CodeBlock { .. }) { // Code blocks don't have user-defined styling (although they do have syntax // highlighting). Use the number of individual runs as an overestimate for the number // of lines. text_block.block.len() } else { // Non-code blocks may only contain a single paragraph. 1 } } /// Calculate the line count to display for hidden blocks. /// Hidden sections at the start and end of a file only ever take up one line. /// Large hidden sections in the middle of a file may take up two lines, since /// they'll have two separate buttons to expand the visible section up or down. fn calculate_hidden_block_line_count( text_block: &StyledTextBlock, location: BlockLocation, ) -> usize { let line_count = estimate_paragraph_count(text_block); gutter_expansion_button_types(&location, line_count).len() } /// Lay out a single text block. This returns both the laid-out block item and a boolean for /// whether or not it has a trailing newline. /// /// In theory, this function shouldn't error, but we've had panics where there /// were no paragraphs. So a `Result` is returned for now so that we can bubble /// up the error and add appropriate logging. See CLD-2093. fn layout_text_block( text_block: StyledTextBlock, layout: &TextLayout, location: BlockLocation, is_hidden: bool, ) -> Result<(BlockItem, bool)> { if is_hidden { // If all text is hidden, return a BlockItem::Hidden without doing any layout let content_length = text_block.content_length; let line_count = calculate_hidden_block_line_count(&text_block, location); return Ok(( BlockItem::Hidden(HiddenBlockConfig::new( line_count.into(), content_length, location, )), false, )); } // Short-circuit before paragraph accumulation for table blocks. if matches!(text_block.style, BufferBlockStyle::Table { .. }) && FeatureFlag::MarkdownTables.is_enabled() { let spacing = layout .rich_text_styles() .block_spacings .from_block_style(&text_block.style); return layout_table_block(text_block, layout, spacing).map(|block| (block, false)); } // Accumulator for the current line (paragraph) of text. let mut active_line = LayOutArgs::new(); // Accumulator for fully laid-out paragraphs. let mut paragraphs = Vec::with_capacity(estimate_paragraph_count(&text_block)); let rich_text_styles = layout.rich_text_styles(); let spacing = rich_text_styles .block_spacings .from_block_style(&text_block.style); let paragraph_styles = layout.paragraph_styles(&text_block.style); if rich_text_styles.highlight_urls { active_line.highlighted_urls = highlight_urls(&text_block.block); } active_line.next_url_index = 0; for run in &text_block.block { let new_line = active_line.layout_run(layout, run, ¶graph_styles); if new_line && !matches!(text_block.style, BufferBlockStyle::Table { .. }) { let offsets = active_line.finish_offsets(); paragraphs.push(Paragraph::new( layout.layout_text( &active_line.text, ¶graph_styles, &spacing, &active_line.style_runs, ), offsets, active_line.content_length, active_line.active_line_url.clone(), spacing, rich_text_styles.minimum_paragraph_height, )); active_line.reset_for_newline(); } } let has_trailing_newline = if !active_line.is_empty() { let offsets = active_line.finish_offsets(); paragraphs.push(Paragraph::new( layout.layout_text( &active_line.text, ¶graph_styles, &spacing, &active_line.style_runs, ), offsets, // This assumes that every line is ended by a newline, block marker, or // block item. Currently, that's true of every paragraph but the last, // if it's plain text without a trailing newline. Acting as if there's // a newline character there lets us render the cursor at the end of // the buffer and distinguish between the end of the last paragraph and // a potential TrailingNewline item. active_line.content_length + 1, active_line.active_line_url, spacing, rich_text_styles.minimum_paragraph_height, )); false } else { true }; let block_item = match text_block.style.clone() { BufferBlockStyle::CodeBlock { code_block_type } => Vec1::try_from_vec(paragraphs) .ok() .map(|p| { let paragraph_block = ParagraphBlock::new(p); BlockItem::RunnableCodeBlock { paragraph_block, code_block_type, } }) .ok_or_else(|| anyhow!("Code block should have at least one paragraph")), BufferBlockStyle::TaskList { indent_level, complete, } => { debug_assert_eq!( paragraphs.len(), 1, "Task list paragraphs should only have one line." ); paragraphs .pop() .map(|paragraph| BlockItem::TaskList { indent_level, complete, paragraph, mouse_state: Default::default(), }) .ok_or_else(|| anyhow!("Task list item should have one paragraph")) } BufferBlockStyle::UnorderedList { indent_level } => { debug_assert_eq!( paragraphs.len(), 1, "Unordered list paragraphs should only have one line." ); paragraphs .pop() .map(|paragraph| BlockItem::UnorderedList { indent_level, paragraph, }) .ok_or_else(|| anyhow!("Unordered list item should have one paragraph")) } BufferBlockStyle::OrderedList { indent_level, number, } => { debug_assert_eq!( paragraphs.len(), 1, "Ordered list paragraphs should only have one line." ); paragraphs .pop() .map(|paragraph| BlockItem::OrderedList { indent_level, number, paragraph, }) .ok_or_else(|| anyhow!("Ordered list item should have one paragraph")) } BufferBlockStyle::Header { header_size } => { debug_assert_eq!( paragraphs.len(), 1, "Header paragraphs should only have one line." ); paragraphs .pop() .map(|paragraph| BlockItem::Header { header_size, paragraph, }) .ok_or_else(|| anyhow!("Header item should have one paragraph")) } BufferBlockStyle::PlainText => { debug_assert_eq!( paragraphs.len(), 1, "Plain text paragraphs should only have one line." ); paragraphs .pop() .map(BlockItem::Paragraph) .ok_or_else(|| anyhow!("Plain text item should have one paragraph")) } BufferBlockStyle::Table { .. } => paragraphs .pop() .map(BlockItem::Paragraph) .ok_or_else(|| anyhow!("Table fallback should have at least one paragraph")), }; block_item.map(|item| (item, has_trailing_newline)) } fn layout_mermaid_diagram_block( text_block: StyledTextBlock, asset_source: AssetSource, config: ImageBlockConfig, location: BlockLocation, is_hidden: bool, ) -> Result<(BlockItem, bool)> { if is_hidden { let line_count = calculate_hidden_block_line_count(&text_block, location); return Ok(( BlockItem::Hidden(HiddenBlockConfig::new( line_count.into(), text_block.content_length, location, )), false, )); } let has_trailing_newline = text_block .block .last() .is_some_and(|run| run.run.ends_with('\n')); Ok(( BlockItem::MermaidDiagram { content_length: text_block.content_length, asset_source, config, }, has_trailing_newline, )) } fn layout_table_block( text_block: StyledTextBlock, layout: &TextLayout, spacing: BlockSpacing, ) -> Result { let table_plain_text = text_block .block .iter() .map(|run| run.run.as_str()) .collect::(); let style_cache = match &text_block.style { BufferBlockStyle::Table { alignments, cache } => { Some(cache.get_or_populate(&table_plain_text, alignments)) } _ => None, }; let owned_cache = if style_cache.is_none() { Some(TableBlockCache::build(&table_plain_text, Vec::new())) } else { None }; let cached = style_cache .or(owned_cache.as_ref()) .expect("cache is always populated from style or owned fallback"); let table = cached.table.clone(); let cell_offset_maps = cached.cell_offset_maps.clone(); let offset_map = cached.offset_map.clone(); let table_style = layout.rich_text_styles().table_style; let mut header_paragraph_style = layout.paragraph_styles(&text_block.style); header_paragraph_style.font_weight = Weight::Bold; header_paragraph_style.text_color = table_style.header_text_color; header_paragraph_style.line_height_ratio = TABLE_LINE_HEIGHT_RATIO; header_paragraph_style.baseline_ratio = TABLE_BASELINE_RATIO; let mut body_paragraph_style = layout.paragraph_styles(&text_block.style); body_paragraph_style.text_color = table_style.text_color; body_paragraph_style.line_height_ratio = TABLE_LINE_HEIGHT_RATIO; body_paragraph_style.baseline_ratio = TABLE_BASELINE_RATIO; let (column_widths, cell_text_layouts) = measure_table_cells( &table, layout, &table_style, header_paragraph_style, body_paragraph_style, ); let cell_links = table_cell_links(&table); let mut row_heights = Vec::with_capacity(cell_text_layouts.len()); let mut cell_text_frames = Vec::with_capacity(cell_text_layouts.len()); let mut cell_layouts = Vec::with_capacity(cell_text_layouts.len()); for row in &cell_text_layouts { let mut row_height = Pixels::zero(); let mut row_frames = Vec::with_capacity(row.len()); let mut row_layouts = Vec::with_capacity(row.len()); for (col_idx, cell) in row.iter().enumerate() { let cell_content_width = (column_widths .get(col_idx) .map(|width| width.as_f32()) .unwrap_or(0.0) - table_style.cell_padding * 2.0) .max(0.0); let frame = layout.layout_text_with_options( &cell.text_layout.text, &cell.paragraph_style, &cell.text_layout.style_runs, cell_content_width, table_column_text_alignment(&table, col_idx), ); let cell_layout = CellLayout::from_text_frame(&frame); let text_height = cell_layout .line_heights .iter() .copied() .sum::() .into_pixels(); let min_height = (cell.paragraph_style.line_height().as_f32() + table_style.cell_padding * 2.0) .into_pixels(); let cell_height = (text_height + (table_style.cell_padding * 2.0).into_pixels()).max(min_height); row_height = row_height.max(cell_height); row_frames.push(frame); row_layouts.push(cell_layout); } row_heights.push(row_height); cell_text_frames.push(row_frames); cell_layouts.push(row_layouts); } let total_height = row_heights .iter() .fold(Pixels::zero(), |acc, row_height| acc + *row_height); let width = column_widths .iter() .fold(Pixels::zero(), |acc, column_width| acc + *column_width); let mut row_y_offsets = Vec::with_capacity(row_heights.len() + 1); row_y_offsets.push(0.0); let mut running_y = 0.0; for height in &row_heights { running_y += height.as_f32(); row_y_offsets.push(running_y); } let mut col_x_offsets = Vec::with_capacity(column_widths.len() + 1); col_x_offsets.push(0.0); let mut running_x = 0.0; for width in &column_widths { running_x += width.as_f32(); col_x_offsets.push(running_x); } let config = TableBlockConfig { width, spacing, style: table_style, }; let content_length = text_block.content_length; let horizontal_scroll_allowed = !layout.container_scrolls_horizontally(); Ok(BlockItem::Table(Box::new(LaidOutTable { table, config, row_heights, column_widths, total_height, offset_map, content_length, cell_offset_maps, row_y_offsets, col_x_offsets, cell_text_frames, cell_layouts, cell_links, scroll_left: Cell::new(Pixels::zero()), scrollbar_interaction_state: Default::default(), horizontal_scroll_allowed, }))) } /// Returns links found in each table cell. /// /// The return type is `Vec>>` where: /// - Outer `Vec`: rows (header row first, then body rows) /// - Middle `Vec`: cells within each row /// - Inner `Vec`: parsed URLs found within a cell fn table_cell_links(table: &FormattedTable) -> Vec>> { std::iter::once(&table.headers) .chain(table.rows.iter()) .map(|row| { row.iter() .map(|cell| { let mut links = Vec::new(); let mut start = 0; for fragment in cell { let end = start + fragment.text.chars().count(); if let Some(Hyperlink::Url(url)) = &fragment.styles.hyperlink { links.push(ParsedUrl::new(start..end, url.clone())); } start = end; } links }) .collect() }) .collect() } fn table_column_text_alignment(table: &FormattedTable, col: usize) -> TextAlignment { match table.alignments.get(col).copied().unwrap_or_default() { TableAlignment::Left => TextAlignment::Left, TableAlignment::Center => TextAlignment::Center, TableAlignment::Right => TextAlignment::Right, } } /// Lay out a single temporary text block. fn layout_temporary_block(content: String, layout: &TextLayout) -> ParagraphBlock { // Accumulator for the current paragraph. let mut active_line = LayOutArgs::new(); let lines = content.lines(); let mut paragraphs = Vec::new(); // Default use the plain text block style and spacing. let styles = layout.paragraph_styles(&BufferBlockStyle::PlainText); let rich_text_styles = layout.rich_text_styles(); let spacing = rich_text_styles .block_spacings .from_block_style(&BufferBlockStyle::PlainText); for run in lines { let mut content = run.to_string(); content.push('\n'); active_line.layout_run( layout, &StyledBufferRun { run: content, text_styles: Default::default(), block_style: BufferBlockStyle::PlainText, }, &styles, ); let offsets = active_line.finish_offsets(); paragraphs.push(Paragraph::new( layout.layout_text( &active_line.text, &styles, &spacing, &active_line.style_runs, ), offsets, active_line.content_length, active_line.active_line_url.clone(), spacing, rich_text_styles.minimum_paragraph_height, )); active_line.reset_for_newline(); } ParagraphBlock::new( Vec1::try_from_vec(paragraphs).expect("Temporary block should have at least one paragraph"), ) } /// Parsed out all urls in the given list of StyledBufferRun. /// /// Links cannot be auto-linked and break up potential URLs. fn highlight_urls(runs: &[StyledBufferRun]) -> Vec { let mut state = UrlDetectionState::default(); for content in runs { let (run_content, has_newline) = if let Some(content) = content.run.strip_suffix('\n') { (content, true) } else { (content.run.as_str(), false) }; state.total_content.push_str(run_content); if content.text_styles.is_link() || content.text_styles.is_placeholder() { state.reset(); // If this run is already a link, it cannot contain additional URLs. Likewise, // placeholders act as URL boundaries. // However, we still need to track positions within the overall block's text. state.frame_offset += run_content.chars().count(); continue; } let mut run_length = 0; for (i, c) in run_content.chars().enumerate() { // Reference to https://docs.rs/urlocator/latest/urlocator/#example-url-boundaries // We know we have fully parsed an url when the locator advances from the `UrlLocation::Url` // to the `UrlLocation::Reset` stage. match state.locator.advance(c) { UrlLocation::Url(length, end_offset) => { // end_offset is the number of characters between the parser location and the // end of the URL (because the parser might be at a `.` or other character // that's valid within a URL but not at its end). let parser_location = i + state.frame_offset; let end = parser_location .checked_sub(end_offset as usize) .expect("Auto-detected URLs cannot span frames"); let start = (end + 1).saturating_sub(length as usize); if let Some(url) = &mut state.last_url { if url.start == start { url.end = end + 1 } else { url.start = start; url.end = end + 1; } } else { state.last_url = Some(start..end); } } UrlLocation::Reset => { state.finish_url(); } _ => (), } run_length += 1; } state.frame_offset += run_length; if has_newline { state.reset(); } } state.finish_url(); state.urls } /// State helper for highlighting URLs. #[derive(Default)] struct UrlDetectionState { /// The starting character offset of the current run within the block's displayed text. This is /// relative to all text frames within the block, not just the one containing the run. frame_offset: usize, /// Accumulator for detected URLs. urls: Vec, /// The URL that is currently being parsed. last_url: Option>, /// Accumulator for the overall [`TextFrame`] content, so that we can extract URL values. total_content: String, locator: UrlLocator, } impl UrlDetectionState { /// Finish the current URL. fn finish_url(&mut self) { if let Some(url) = self.last_url.take() && let Some(link) = char_slice(&self.total_content, url.start, url.end) { self.urls.push(ParsedUrl { url_range: url.start..url.end, link: link.to_string(), }) } } /// Reset the detection state after encountering something that forces a URL boundary (such as /// a newline or hyperlink). fn reset(&mut self) { self.finish_url(); // Reset the UrlLocator so that text surrounding the break isn't detected as // a URL. We can do this because UrlLocator reports a length and offset from the // current end position, which we can use as long as block_offset is correct. self.locator = UrlLocator::new(); } }