1416 lines
52 KiB
Rust
1416 lines
52 KiB
Rust
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<ThemeFill>,
|
|
pub inline_text_decorations: Vec<Decoration>,
|
|
}
|
|
|
|
/// 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<CharOffset>,
|
|
/// The old point range that was replaced (before this edit).
|
|
pub replaced_points: Range<Point>,
|
|
/// 1-indexed byte range of the old content that was replaced (before this edit).
|
|
pub replaced_byte_range: Range<ByteOffset>,
|
|
/// 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<CharOffset>,
|
|
}
|
|
|
|
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<PreciseDelta>,
|
|
/// 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<CharOffset>,
|
|
/// Content of the lines that have been changed.
|
|
pub new_lines: Vec<StyledBufferBlock>,
|
|
}
|
|
|
|
/// 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<CharOffset>,
|
|
pub laid_out_line: Vec<BlockItem>,
|
|
/// 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<Cursor>,
|
|
}
|
|
|
|
/// 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<usize>,
|
|
link: String,
|
|
}
|
|
|
|
impl ParsedUrl {
|
|
pub(crate) fn new(url_range: Range<usize>, link: String) -> Self {
|
|
Self { url_range, link }
|
|
}
|
|
pub fn url_range(&self) -> Range<usize> {
|
|
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<usize>, 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<SelectableTextRun>,
|
|
/// List of detected urls.
|
|
highlighted_urls: Vec<ParsedUrl>,
|
|
/// 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<ParsedUrl>,
|
|
}
|
|
|
|
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<Pixels>, Vec<Vec<TableCellTextLayout>>) {
|
|
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<StyledBufferRun> = 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<RangeSet<CharOffset>>,
|
|
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<TemporaryBlock>,
|
|
layout: &TextLayout,
|
|
) -> HashMap<LineCount, Vec<BlockItem>> {
|
|
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<dyn LaidOutEmbeddedItem>),
|
|
/// 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<PathBuf>,
|
|
},
|
|
/// A temporary text block.
|
|
TemporaryBlock {
|
|
content: String,
|
|
line_decoration: Option<ThemeFill>,
|
|
decoration: Vec<Decoration>,
|
|
},
|
|
}
|
|
|
|
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::<String>();
|
|
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<ThemeFill>,
|
|
decoration: Vec<Decoration>,
|
|
) -> 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<BlockItem> {
|
|
let table_plain_text = text_block
|
|
.block
|
|
.iter()
|
|
.map(|run| run.run.as_str())
|
|
.collect::<String>();
|
|
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::<f32>()
|
|
.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<Vec<Vec<ParsedUrl>>>` 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<Vec<Vec<ParsedUrl>>> {
|
|
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<ParsedUrl> {
|
|
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<ParsedUrl>,
|
|
/// The URL that is currently being parsed.
|
|
last_url: Option<Range<usize>>,
|
|
/// 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();
|
|
}
|
|
}
|