first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+4 -7
View File
@@ -1,16 +1,13 @@
use std::{
collections::HashMap,
sync::{Arc, Weak},
};
use sum_tree::SumTree;
use std::collections::HashMap;
use std::sync::{Arc, Weak};
use string_offset::CharOffset;
use sum_tree::SumTree;
use super::text::BufferText;
#[cfg(test)]
#[path = "anchor_test.rs"]
#[path = "anchor_tests.rs"]
mod test;
/// Handle to a particular anchor. As long as there is an active handle, the
@@ -1,18 +1,15 @@
use std::cmp::Ordering;
use galaxyui::App;
use string_offset::CharOffset;
use sum_tree::SumTree;
use galaxyui_core::App;
use super::{AnchorSide, Anchors};
use crate::content::{
anchor::{Anchor, AnchorUpdate},
buffer::Buffer,
cursor::BufferSumTree,
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use string_offset::CharOffset;
use crate::content::anchor::{Anchor, AnchorUpdate};
use crate::content::buffer::Buffer;
use crate::content::cursor::BufferSumTree;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::text::IndentBehavior;
#[test]
fn test_anchor_cleanup() {
+110 -49
View File
@@ -1,5 +1,10 @@
use core::fmt;
use galaxy_util::content_version::ContentVersion;
use std::iter::{self, FusedIterator, once};
use std::mem;
use std::ops::Range;
use std::sync::Arc;
use enum_iterator::all;
use itertools::{Either, Itertools};
use line_ending::LineEnding;
use markdown_parser::{
@@ -9,62 +14,55 @@ use markdown_parser::{
};
use num_traits::SaturatingSub;
use pathfinder_color::ColorU;
use rand::{Rng, distributions::Alphanumeric};
use rand::Rng;
use rand::distributions::Alphanumeric;
use serde_yaml::Mapping;
use std::{
iter::{self, FusedIterator, once},
mem,
ops::Range,
sync::Arc,
};
use vec1::{Vec1, vec1};
use super::{
anchor::{Anchor, AnchorSide, Anchors},
cursor::BufferCursor,
edit::EditDelta,
markdown::{BufferMarkdownParser, BufferToFormattedText, ExportedBufferBlocks, MarkdownStyle},
selection::{Selection, TextStyleBias},
text::{
BlockCount, BlockLineBreakBehavior, BlockType, BufferBlockItem, BufferBlockStyle,
BufferSummary, BufferText, BufferTextStyle, Bytes, CodeBlockType, IndentBehavior,
LineCount, LinkCount, LinkMarker, MarkerDir, StyleSummary, SyntaxColorId, TextStyles,
TextStylesWithMetadata, TextSummary, inline_to_text,
},
undo::{NonAtomicType, UndoActionType, UndoArg, UndoStack},
validation::validate_content,
};
use galaxy_core::{platform::SessionPlatform, safe_error};
use crate::{
content::{
anchor::AnchorUpdate,
core::{CoreEditorAction, CoreEditorActionType, RangeAnchors},
cursor::BufferSumTree,
edit::PreciseDelta,
selection_model::{BufferSelectionModel, SelectionSnapshot},
text::{ColorMarker, IndentUnit},
undo::{ReversibleEditorActions, ReversibleSelectionState},
version::BufferVersion,
},
multiline::{self, AnyMultilineString, LF, MultilineString},
render::model::{EmbeddedItem, RenderedSelection, RenderedSelectionBias, RenderedSelectionSet},
};
use enum_iterator::all;
use galaxyui::{AppContext, Entity, ModelContext};
use galaxyui::{EntityId, ModelHandle, elements::ListIndentLevel};
use galaxyui::{
fonts::Weight,
text::{TextBuffer, char_slice, point::Point},
};
use string_offset::{ByteOffset, CharOffset};
use sum_tree::{SeekBias, SumTree};
use vec1::{Vec1, vec1};
use galaxy_core::platform::SessionPlatform;
use galaxy_core::safe_error;
use galaxy_util::content_version::ContentVersion;
use galaxyui_core::elements::ListIndentLevel;
use galaxyui_core::fonts::Weight;
use galaxyui_core::text::point::Point;
use galaxyui_core::text::{TextBuffer, char_slice};
use galaxyui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle};
use super::anchor::{Anchor, AnchorSide, Anchors};
use super::cursor::BufferCursor;
use super::edit::EditDelta;
use super::markdown::{
BufferMarkdownParser, BufferToFormattedText, ExportedBufferBlocks, MarkdownStyle,
};
use super::selection::{Selection, TextStyleBias};
use super::text::{
BlockCount, BlockLineBreakBehavior, BlockType, BufferBlockItem, BufferBlockStyle,
BufferSummary, BufferText, BufferTextStyle, Bytes, CodeBlockType, IndentBehavior, LineCount,
LinkCount, LinkMarker, MarkerDir, StyleSummary, SyntaxColorId, TextStyles,
TextStylesWithMetadata, TextSummary, inline_to_text,
};
use super::undo::{NonAtomicType, UndoActionType, UndoArg, UndoStack};
use super::validation::validate_content;
use crate::content::anchor::AnchorUpdate;
use crate::content::core::{CoreEditorAction, CoreEditorActionType, RangeAnchors};
use crate::content::cursor::BufferSumTree;
use crate::content::edit::PreciseDelta;
use crate::content::selection_model::{BufferSelectionModel, SelectionSnapshot};
use crate::content::text::{ColorMarker, IndentUnit};
use crate::content::undo::{ReversibleEditorActions, ReversibleSelectionState};
use crate::content::version::BufferVersion;
use crate::multiline::{self, AnyMultilineString, LF, MultilineString};
use crate::render::model::{
EmbeddedItem, RenderedSelection, RenderedSelectionBias, RenderedSelectionSet,
};
/// Format of the passed in text.
#[derive(Clone, Debug, Copy)]
pub enum ContentFormat {
Markdown,
PlainText,
Ipynb,
}
/// Configuration struct that holds all the fields needed to reset the entire editor.
@@ -95,6 +93,15 @@ impl<'a> InitialBufferState<'a> {
}
}
/// Create a new InitialBufferState with Jupyter notebook (`.ipynb`) format
pub fn ipynb(text: &'a str) -> Self {
Self {
text,
format: ContentFormat::Ipynb,
version: ContentVersion::new(),
}
}
/// Set the content version
pub fn with_version(mut self, version: ContentVersion) -> Self {
self.version = version;
@@ -538,6 +545,10 @@ impl BufferSnapshot {
pub fn bytes(&self) -> Bytes<'_> {
Bytes::from_sum_tree(&self.content, ByteOffset::from(0), self.byte_len)
}
pub fn byte_len(&self) -> usize {
self.byte_len.as_usize()
}
}
/// Model for storing the content of an editor.
@@ -862,6 +873,31 @@ impl Buffer {
)
}
/// Construct a [`Buffer`] from the JSON contents of a `.ipynb` (Jupyter)
/// notebook, converting it directly into formatted text.
///
/// Returns an [`ipynb_parser::IpynbError`] if the input is not a parseable
/// nbformat v4 notebook, so callers can decide how to present invalid
/// notebooks (e.g. routing to a raw text editor) rather than rendering a
/// blank or misleading view.
pub(crate) fn from_ipynb(
ipynb: &str,
embedded_item_conversion: Option<EmbeddedItemConversion>,
tab_indentation: TabIndentation,
selection_model: ModelHandle<BufferSelectionModel>,
ctx: &mut ModelContext<Self>,
) -> Result<Self, ipynb_parser::IpynbError> {
let gfm_tables = galaxy_core::features::FeatureFlag::MarkdownTables.is_enabled();
let formatted_text = ipynb_parser::ipynb_to_formatted_text(ipynb, gfm_tables)?;
Ok(Self::from_formatted_text(
formatted_text,
embedded_item_conversion,
tab_indentation,
selection_model,
ctx,
))
}
fn replace(
&mut self,
state: InitialBufferState,
@@ -911,6 +947,28 @@ impl Buffer {
selection_model.clone(),
ctx,
),
ContentFormat::Ipynb => match Buffer::from_ipynb(
state.text,
callback,
indentation,
selection_model.clone(),
ctx,
) {
Ok(buffer) => buffer,
Err(e) => {
safe_error! {
safe: ("Failed to render Jupyter notebook; showing raw contents"),
full: ("Failed to render Jupyter notebook: {e}")
}
Buffer::from_formatted_text(
ipynb_parser::raw_fallback_formatted_text(state.text),
callback,
Box::new(|_, _| IndentBehavior::Ignore),
selection_model.clone(),
ctx,
)
}
},
};
// Infer line ending from the new content and restore session_platform.
@@ -4493,6 +4551,9 @@ impl Buffer {
ctx: &mut ModelContext<Self>,
) {
if edits.is_empty() {
// TODO: This is temporary. We will add support to properly maintain the undo stack after incremental updates.
self.reset_undo_stack();
self.set_version(new_version);
return;
}
@@ -6118,5 +6179,5 @@ pub(super) enum BoundaryEdge {
}
#[cfg(test)]
#[path = "buffer_test.rs"]
#[path = "buffer_tests.rs"]
pub mod tests;
@@ -12,12 +12,19 @@ use pathfinder_color::ColorU;
use rand::SeedableRng;
use rand::rngs::StdRng;
use serde_yaml::{Mapping, Value};
use string_offset::{ByteOffset, CharOffset};
use vec1::{Vec1, vec1};
use galaxy_util::content_version::ContentVersion;
use galaxyui_core::elements::ListIndentLevel;
use galaxyui_core::text::point::Point;
use galaxyui_core::{App, AppContext, ModelContext, ModelHandle, ReadModel};
use super::{BufferEvent, EditResult, ToBufferCharOffset};
use crate::content::buffer::{
AutoScrollBehavior, BufferEditAction, BufferSelectAction, EditOrigin, EmbeddedItemConversion,
InitialBufferState, SelectionOffsets, StyledBlockBoundaryBehavior, StyledBufferBlock,
StyledTextBlock, TabIndentation, ToBufferByteOffset, ToBufferPoint,
AutoScrollBehavior, Buffer, BufferEditAction, BufferSelectAction, EditOrigin,
EmbeddedItemConversion, InitialBufferState, SelectionOffsets, StyledBlockBoundaryBehavior,
StyledBufferBlock, StyledBufferRun, StyledTextBlock, TabIndentation, ToBufferByteOffset,
ToBufferPoint,
};
use crate::content::core::{CoreEditorAction, CoreEditorActionType};
use crate::content::cursor::BufferSumTree;
@@ -37,14 +44,6 @@ use crate::render::model::{
EmbeddedItem, EmbeddedItemHTMLRepresentation, EmbeddedItemRichFormat, LaidOutEmbeddedItem,
RenderedSelectionSet,
};
use galaxyui::elements::ListIndentLevel;
use galaxyui::text::point::Point;
use string_offset::ByteOffset;
use string_offset::CharOffset;
use crate::content::buffer::{Buffer, StyledBufferRun};
use super::{BufferEvent, EditResult, ToBufferCharOffset};
#[derive(Debug)]
pub struct TestEmbeddedItem {
@@ -14166,6 +14165,65 @@ fn test_undo_redo_versions() {
});
}
/// Regression test: after a remote file save, the server's file-watcher sends a
/// `BufferUpdatedPush` with the same content the client just saved.
/// `insert_at_char_offset_ranges` is a no-op (content unchanged) and returns early
/// without calling `set_version(new_version)`. But the caller updates
/// `base_content_version` to `new_version` anyway, creating a mismatch that makes
/// `has_unsaved_changes` return true — causing a spurious "Save changes?" dialog.
#[test]
fn test_insert_at_char_offset_ranges_noop_skips_set_version() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
// Step 1: Simulate initial file load — populate buffer and set version.
buffer.replace_all("hello world", ctx);
let load_version = ContentVersion::new();
buffer.set_version(load_version);
assert!(buffer.version_match(&load_version));
// Step 2: User edits the buffer.
buffer.update_content(
BufferEditAction::Insert {
text: "!",
style: TextStyles::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
selection.clone(),
ctx,
);
assert!(!buffer.version_match(&load_version));
// Step 3: Simulate save — capture the current buffer version as the base.
let save_version = buffer.version();
assert!(buffer.version_match(&save_version));
// Step 4: Simulate post-save server push with NO edits.
// The server file-watcher detected the save, computed a diff against
// the buffer content, and found zero changes — so the push carries
// an empty edit list.
let push_version = ContentVersion::new();
buffer.insert_at_char_offset_ranges(vec![], push_version, ctx);
// Step 5: The caller (GlobalBufferModel) would set base_content_version = push_version.
// version_match(push_version) should be true — the content hasn't changed.
// BUG: insert_at_char_offset_ranges returned early without calling set_version,
// so the buffer version is still save_version, not push_version.
assert!(
buffer.version_match(&push_version),
"version_match should return true after a no-op insert_at_char_offset_ranges, \
but the buffer version was not updated because the no-op early return \
skipped set_version. Buffer version is {:?}, expected to match {:?}",
buffer.version(),
push_version,
);
});
});
}
#[test]
fn test_insert_at_offsets() {
App::test((), |mut app| async move {
@@ -14217,6 +14275,60 @@ fn test_insert_at_offsets() {
});
}
/// Regression test for WARP-CLIENT-DEV-NYY: panic "Invalid edit range 4042..3982".
///
/// Root cause: `fuzzy_match_v4a_diffs` produces `DiffDelta`s with overlapping
/// `replacement_line_range` values when multiple V4A hunks target the same
/// region of a file (confirmed by the companion test
/// `test_v4a_maa_crash_d71bf84b_no_overlapping_deltas` in the `ai` crate).
///
/// `CodeEditorModel::apply_diffs` converts those line ranges to char offsets
/// and passes them to `insert_at_offsets`, which feeds them into
/// `apply_core_edit_actions` without validating. The invalid range reaches
/// `Buffer::edit`, which panics on the `debug_assert!`.
///
/// This test passes the exact Sentry crash values (`4042..3982`) to
/// `insert_at_offsets` to confirm the editor does not defend against bad
/// input from the diff layer.
#[test]
fn test_insert_at_offsets_overlapping_ranges_skipped() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
// Populate the buffer with enough content.
let content = (0..200)
.map(|i| format!("line_{:03}_content_padding", i))
.collect::<Vec<_>>()
.join("\n");
buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
&content,
Default::default(),
selection.clone(),
ctx,
);
let original_text = buffer.text().into_string();
// Pass a range with start > end (the exact Sentry crash values).
// After the fix in apply_core_edit_actions, the inverted range
// should be skipped gracefully instead of panicking.
let edits = Vec1::try_from_vec(vec![(
"replacement\n".to_string(),
CharOffset::from(4042)..CharOffset::from(3982),
)])
.unwrap();
buffer.insert_at_offsets(&edits, selection.clone(), ctx);
// Buffer should be unchanged — the invalid edit was skipped.
assert_eq!(buffer.text().into_string(), original_text);
});
});
}
#[test]
fn test_from_plain_text() {
App::test((), |mut app| async move {
+65 -21
View File
@@ -1,29 +1,45 @@
use super::{
buffer::{Buffer, EditOrigin, EditResult},
cursor::BufferSumTree,
edit::EditDelta,
text::{
BlockType, BufferTextStyle, ColorMarker, LinkCount, LinkMarker, MarkerDir, SyntaxColorId,
TextStyles, TextStylesWithMetadata,
},
undo::{ReversibleEditorAction, UndoArg},
};
use crate::content::{
anchor::{Anchor, AnchorSide, AnchorUpdate},
buffer::{StyledBlockBoundaryBehavior, ToBufferByteOffset, ToBufferPoint},
cursor::BufferCursor,
edit::PreciseDelta,
text::{
BlockHeaderSize, BlockLineBreakBehavior, BufferBlockItem, BufferBlockStyle, BufferText,
StyleSummary,
},
};
use std::ops::Range;
use enum_iterator::all;
use galaxyui::elements::ListIndentLevel;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use std::ops::Range;
use string_offset::CharOffset;
use sum_tree::SumTree;
use galaxyui_core::elements::ListIndentLevel;
use super::buffer::{Buffer, EditOrigin, EditResult};
use super::cursor::BufferSumTree;
use super::edit::EditDelta;
use super::text::{
BlockType, BufferTextStyle, ColorMarker, LinkCount, LinkMarker, MarkerDir, SyntaxColorId,
TextStyles, TextStylesWithMetadata,
};
use super::undo::{ReversibleEditorAction, UndoArg};
use crate::content::anchor::{Anchor, AnchorSide, AnchorUpdate};
use crate::content::buffer::{StyledBlockBoundaryBehavior, ToBufferByteOffset, ToBufferPoint};
use crate::content::cursor::BufferCursor;
use crate::content::edit::PreciseDelta;
use crate::content::text::{
BlockHeaderSize, BlockLineBreakBehavior, BufferBlockItem, BufferBlockStyle, BufferText,
StyleSummary,
};
/// Placeholder shown in place of an embedded image whose `data:` payload
/// exceeds the asset layer's render limit (see
/// `asset_cache::data_uri_exceeds_limit`).
const IMAGE_TOO_LARGE_PLACEHOLDER: &str = "Image too large to display";
fn replace_oversized_data_uri_images(mut text: FormattedText) -> FormattedText {
for line in text.lines.iter_mut() {
if let FormattedTextLine::Image(image) = line
&& asset_cache::data_uri_exceeds_limit(&image.source)
{
*line = FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(
IMAGE_TOO_LARGE_PLACEHOLDER,
)]);
}
}
text
}
#[derive(Debug, Clone)]
pub struct CoreEditorAction {
@@ -215,6 +231,19 @@ impl Buffer {
.resolve(&anchors.end)
.expect("Anchor should exist");
log::trace!("Start anchor => {edit_start}, end anchor => {edit_end}");
// Safety: if a previous edit in this batch caused the anchors for this
// action to cross (start > end), skip the action rather than panicking.
// This can happen when overlapping DiffDeltas slip through the diff
// matching layer (see WARP-CLIENT-DEV-NYY).
if edit_start > edit_end {
log::warn!(
"Skipping edit action with inverted range {edit_start}..{edit_end} \
(anchors crossed after a prior edit in the same batch)"
);
continue;
}
let edit_range = edit_start..edit_end;
let replaced_points = self.offset_range_to_point_range(edit_range.clone());
@@ -293,6 +322,12 @@ impl Buffer {
new_range_anchors.push(new_anchors);
}
// If every action in the batch was skipped (e.g. all had inverted ranges),
// there is nothing to commit — return early.
if new_range_anchors.is_empty() {
return EditResult::default();
}
// Resolve each delta's new content range anchors against the final buffer state.
// If a later action in the batch deletes the content an earlier action inserted,
// the earlier action's anchors will have been invalidated — drop those deltas.
@@ -525,6 +560,11 @@ impl Buffer {
// as it is.
let mut inherit_styling = source.from_user();
// Replace any embedded `data:` image whose payload exceeds the asset
// layer's render limit with a visible placeholder before lowering lines
// into the buffer, so an over-limit image surfaces a hint instead of
// silently failing to load.
let text = replace_oversized_data_uri_images(text);
for line in text.lines {
should_override_next_block_style = false;
match line {
@@ -1617,3 +1657,7 @@ fn maybe_push_new_block_marker(
});
}
}
#[cfg(test)]
#[path = "core_tests.rs"]
mod tests;
+41
View File
@@ -0,0 +1,41 @@
use markdown_parser::{FormattedImage, FormattedText, FormattedTextLine};
use super::*;
/// A base64 `data:` image whose payload exceeds the asset layer's render limit.
fn oversized_data_uri_image() -> FormattedImage {
let payload = "A".repeat(asset_cache::MAX_DATA_URI_PAYLOAD_BYTES + 1);
FormattedImage {
alt_text: "output".to_string(),
source: format!("data:image/png;base64,{payload}"),
title: None,
}
}
#[test]
fn replace_oversized_data_uri_images_swaps_in_placeholder() {
// An in-limit image that must be left untouched.
let small = FormattedImage {
alt_text: "output".to_string(),
source: "data:image/png;base64,iVBORw0KGgo=".to_string(),
title: None,
};
let text = FormattedText::new(vec![
FormattedTextLine::Image(oversized_data_uri_image()),
FormattedTextLine::Image(small.clone()),
]);
let result = replace_oversized_data_uri_images(text);
let lines: Vec<_> = result.lines.into_iter().collect();
// The oversized image becomes a visible placeholder text line ...
assert!(matches!(&lines[0], FormattedTextLine::Line(_)));
assert_eq!(
lines[0].raw_text(),
format!("{IMAGE_TOO_LARGE_PLACEHOLDER}\n")
);
// ... while an in-limit image is left untouched.
assert_eq!(lines[1], FormattedTextLine::Image(small));
}
+1 -1
View File
@@ -455,5 +455,5 @@ where
}
#[cfg(test)]
#[path = "cursor_test.rs"]
#[path = "cursor_tests.rs"]
mod tests;
@@ -1,9 +1,8 @@
use string_offset::CharOffset;
use sum_tree::SumTree;
use crate::content::text::{BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir};
use string_offset::CharOffset;
use super::{BufferCursor, BufferSumTree};
use crate::content::text::{BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir};
/// Helper function to count the number of Text fragments in a SumTree
fn count_text_fragments(tree: &SumTree<BufferText>) -> usize {
+2 -1
View File
@@ -3,8 +3,9 @@
//! This module provides functionality for computing minimal diffs between two text strings,
//! which is used for auto-reloading files without wiping undo history or disrupting anchors.
use imara_diff::{Algorithm, Diff, InternedInput, Token};
use std::ops::Range;
use imara_diff::{Algorithm, Diff, InternedInput, Token};
use string_offset::{ByteOffset, CharOffset};
use super::buffer::{Buffer, ToBufferCharOffset};
+175 -58
View File
@@ -1,60 +1,72 @@
use std::{
cell::Cell,
collections::HashMap,
mem,
ops::Range,
path::{Path, PathBuf},
};
use std::cell::Cell;
use std::collections::HashMap;
use std::mem;
use std::ops::Range;
use std::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 string_offset::{ByteOffset, CharOffset};
use urlocator::{UrlLocation, UrlLocator};
use vec1::Vec1;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::Fill as ThemeFill;
use galaxyui_core::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui_core::fonts::Weight;
use galaxyui_core::image_cache::ImageType;
use galaxyui_core::text::char_slice;
use galaxyui_core::text::point::Point;
use galaxyui_core::text_layout::{StyleAndFont, TextAlignment};
use galaxyui_core::units::{IntoPixels, Pixels};
use galaxyui_core::{AppContext, SingletonEntity};
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 super::buffer::{StyledBufferBlock, StyledBufferRun, StyledTextBlock};
use super::mermaid_diagram::{mermaid_asset_source, mermaid_diagram_layout};
use super::text::{
BufferBlockItem, BufferBlockStyle, CodeBlockType, FormattedTable, TableBlockCache,
};
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},
use crate::parallel_util::Last;
use crate::render::layout::{InlineTextLayoutInput, TextLayout, add_link_to_style_and_font};
use crate::render::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 crate::render::{TABLE_BASELINE_RATIO, TABLE_LINE_HEIGHT_RATIO};
#[cfg(test)]
#[path = "edit_tests.rs"]
mod tests;
#[cfg(any(test, feature = "test-util"))]
#[allow(dead_code)]
pub(crate) fn layout_mermaid_block_for_test(
text_block: StyledTextBlock,
layout: &TextLayout,
layout_options: RenderLayoutOptions,
app: &AppContext,
) -> Result<(BlockItem, bool)> {
let task = LayoutTask::from_styled_block(
StyledBufferBlock::Text(text_block),
layout,
&layout_options,
CharOffset::from(1),
app,
None,
);
task.run(layout, BlockLocation::Middle, false)
}
/// 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
/// - Inline data: base64 `data:` URIs (e.g. notebook image outputs)
/// - URLs: `http://` or `https://` prefixed paths
/// - Absolute paths: paths starting with `/`
/// - Relative paths: all other paths, resolved relative to the document location
@@ -65,11 +77,14 @@ pub fn resolve_asset_source_relative_to_directory(
source: &str,
base_directory: Option<&Path>,
) -> AssetSource {
if source.starts_with("http://") || source.starts_with("https://") {
if let Some(data_uri_source) = asset_cache::data_uri_source(source) {
data_uri_source
} else 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(),
content_version: None,
}
} else {
let resolved_path = if let Some(base_directory) = base_directory {
@@ -83,6 +98,7 @@ pub fn resolve_asset_source_relative_to_directory(
Ok(canon) => canon.to_string_lossy().to_string(),
Err(_) => resolved_path.to_string_lossy().to_string(),
},
content_version: None,
}
}
}
@@ -97,11 +113,14 @@ pub fn resolve_asset_source_relative_to_directory(
source: &str,
_base_directory: Option<&Path>,
) -> AssetSource {
if source.starts_with("http://") || source.starts_with("https://") {
if let Some(data_uri_source) = asset_cache::data_uri_source(source) {
data_uri_source
} else if source.starts_with("http://") || source.starts_with("https://") {
asset_cache::url_source(source)
} else {
AssetSource::LocalFile {
path: source.to_string(),
content_version: None,
}
}
}
@@ -487,7 +506,7 @@ impl EditDelta {
self,
layout: &TextLayout,
document_path: Option<&Path>,
layout_options: RenderLayoutOptions,
layout_options: &RenderLayoutOptions,
hidden_ranges: Option<RangeSet<CharOffset>>,
app: &AppContext,
) -> LaidOutRenderDelta {
@@ -505,10 +524,12 @@ impl EditDelta {
if content_length == CharOffset::zero() {
None
} else {
let block_start = current_offset;
let task = LayoutTask::from_styled_block(
block,
layout,
layout_options,
block_start,
app,
document_path,
);
@@ -646,6 +667,14 @@ enum LayoutTask {
asset_source: AssetSource,
config: ImageBlockConfig,
},
/// A Mermaid-labeled code block whose contents are not currently renderable as a diagram
/// (either the render hasn't completed yet or it failed to parse). This lays out identically
/// to [`Self::Text`] but carries the Mermaid asset source so that the view layer can watch
/// for the asset to finish loading and re-run layout if it becomes parseable.
MermaidCodeFallback {
text_block: StyledTextBlock,
pending_mermaid_asset: Option<AssetSource>,
},
/// A horizontal rule, which requires no layout.
HorizontalRule(HorizontalRuleConfig),
/// An image, which requires no layout.
@@ -668,7 +697,8 @@ impl LayoutTask {
fn from_styled_block(
content: StyledBufferBlock,
layout: &TextLayout,
layout_options: RenderLayoutOptions,
layout_options: &RenderLayoutOptions,
block_start: CharOffset,
app: &AppContext,
document_path: Option<&Path>,
) -> Self {
@@ -720,29 +750,95 @@ impl LayoutTask {
}
},
StyledBufferBlock::Text(text_block) => {
if layout_options.render_mermaid_diagrams
&& matches!(
text_block.style,
BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
}
)
{
let is_mermaid = matches!(
text_block.style,
BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
}
);
let is_user_rendered = layout_options.mermaid_render_offsets.contains(&block_start);
let should_render =
is_mermaid && (layout_options.render_mermaid_diagrams || is_user_rendered);
if should_render {
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,
// Empty Mermaid blocks can never be rendered as a diagram, so avoid
// kicking off any render work and fall through to a plain code block.
if source.trim().is_empty() {
return Self::MermaidCodeFallback {
text_block,
pending_mermaid_asset: None,
};
}
let asset_source = mermaid_asset_source(&source);
let asset_cache = AssetCache::as_ref(app);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loaded { .. } => {
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,
}
}
AssetState::Loading { .. } => {
if is_user_rendered {
// User explicitly chose Rendered — show diagram frame immediately
// (render element will display loading placeholder).
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 {
// Auto-render mode: stay in code-block view while loading.
Self::MermaidCodeFallback {
text_block,
pending_mermaid_asset: Some(asset_source),
}
}
}
AssetState::FailedToLoad(_) | AssetState::Evicted => {
if is_user_rendered {
// User explicitly chose Rendered — show diagram frame with error.
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 {
// Rendering failed. Keep in code-block view so the user can
// see and edit the raw text. No need to keep watching: the
// asset state will not change until the source (and thus the
// asset key) does.
Self::MermaidCodeFallback {
text_block,
pending_mermaid_asset: None,
}
}
}
}
} else {
Self::Text(text_block)
@@ -792,6 +888,26 @@ impl LayoutTask {
))
}
Self::Text(text_block) => layout_text_block(text_block, layout, location, is_hidden),
Self::MermaidCodeFallback {
text_block,
pending_mermaid_asset,
} => {
let (block_item, has_trailing_newline) =
layout_text_block(text_block, layout, location, is_hidden)?;
let block_item = match block_item {
BlockItem::RunnableCodeBlock {
paragraph_block,
code_block_type,
..
} => BlockItem::RunnableCodeBlock {
paragraph_block,
code_block_type,
pending_mermaid_asset,
},
other => other,
};
Ok((block_item, has_trailing_newline))
}
Self::MermaidDiagram {
text_block,
asset_source,
@@ -952,6 +1068,7 @@ fn layout_text_block(
BlockItem::RunnableCodeBlock {
paragraph_block,
code_block_type,
pending_mermaid_asset: None,
}
})
.ok_or_else(|| anyhow!("Code block should have at least one paragraph")),
+293 -24
View File
@@ -1,28 +1,28 @@
use std::path::Path;
use string_offset::CharOffset;
use galaxy_core::features::FeatureFlag;
use galaxyui_core::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui_core::fonts::{Properties, Style, Weight};
use galaxyui_core::image_cache::ImageType;
use galaxyui_core::text_layout::{LayoutCache, StyleAndFont, TextStyle};
use galaxyui_core::{App, SingletonEntity};
use super::{
BlockLocation, LayOutArgs, layout_mermaid_diagram_block, layout_table_block, layout_text_block,
};
use crate::{
content::{
buffer::{StyledBufferRun, StyledTextBlock},
edit::{ParsedUrl, highlight_urls, resolve_asset_source_relative_to_directory},
mermaid_diagram::{mermaid_asset_source, mermaid_diagram_layout},
text::{BufferBlockStyle, CodeBlockType, TextStylesWithMetadata},
},
render::{
layout::{TextLayout, add_link_to_style_and_font, markdown_inline_to_text_and_style_runs},
model::{BlockItem, test_utils::TEST_STYLES},
},
use crate::content::buffer::{StyledBufferRun, StyledTextBlock};
use crate::content::edit::{
ParsedUrl, highlight_urls, layout_mermaid_block_for_test,
resolve_asset_source_relative_to_directory,
};
use galaxy_core::features::FeatureFlag;
use galaxyui::{
App, SingletonEntity,
assets::asset_cache::{AssetCache, AssetSource, AssetState},
fonts::{Properties, Style, Weight},
image_cache::ImageType,
text_layout::{LayoutCache, StyleAndFont, TextStyle},
use crate::content::mermaid_diagram::{mermaid_asset_source, mermaid_diagram_layout};
use crate::content::text::{BufferBlockStyle, CodeBlockType, TextStylesWithMetadata};
use crate::render::layout::{
TextLayout, add_link_to_style_and_font, markdown_inline_to_text_and_style_runs,
};
use std::path::Path;
use string_offset::CharOffset;
use crate::render::model::test_utils::TEST_STYLES;
use crate::render::model::{BlockItem, RenderLayoutOptions};
#[test]
fn test_highlight_urls() {
@@ -362,13 +362,12 @@ fn test_layout_mermaid_block_uses_loaded_svg_aspect_ratio() {
..
} => {
let intrinsic_size = svg.size();
let expected_width = (800.
let expected_width = 800.
- TEST_STYLES
.block_spacings
.from_block_style(&block_style)
.x_axis_offset()
.as_f32())
.min(intrinsic_size.width());
.as_f32();
let expected_height =
expected_width * intrinsic_size.height() / intrinsic_size.width();
assert_eq!(*content_length, CharOffset::from(content.chars().count()));
@@ -384,13 +383,283 @@ fn test_layout_mermaid_block_uses_loaded_svg_aspect_ratio() {
})
}
#[test]
fn test_unloaded_mermaid_diagram_uses_stable_full_width_placeholder_height() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let contents = "graph TD\nA[Unloaded] --> B[Placeholder]\n";
let block_style = BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
};
let spacing = TEST_STYLES.block_spacings.from_block_style(&block_style);
let (_asset_source, config) =
mermaid_diagram_layout(contents, &text_layout, spacing, ctx);
let expected_width = 800. - spacing.x_axis_offset().as_f32();
let expected_height = TEST_STYLES.base_line_height().as_f32() * 10.;
assert!(
(config.width.as_f32() - expected_width).abs() < 0.5,
"expected unloaded Mermaid diagram width {} to use full available width {}",
config.width.as_f32(),
expected_width,
);
assert!(
(config.height.as_f32() - expected_height).abs() < 0.5,
"expected unloaded Mermaid diagram height {} to use stable placeholder height {}",
config.height.as_f32(),
expected_height,
);
});
})
}
fn mermaid_code_block(contents: &str) -> StyledTextBlock {
let block_style = BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
};
StyledTextBlock {
block: vec![StyledBufferRun {
run: contents.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: block_style.clone(),
}],
style: block_style,
content_length: CharOffset::from(contents.chars().count()),
}
}
fn mermaid_layout_options() -> RenderLayoutOptions {
RenderLayoutOptions {
render_mermaid_diagrams: true,
..Default::default()
}
}
#[test]
fn test_empty_mermaid_block_lays_out_as_code_block() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let block = mermaid_code_block("\n");
let (item, _has_trailing_newline) =
layout_mermaid_block_for_test(block, &text_layout, mermaid_layout_options(), ctx)
.expect("layout should succeed");
match item {
BlockItem::RunnableCodeBlock {
code_block_type,
pending_mermaid_asset,
..
} => {
assert_eq!(code_block_type, CodeBlockType::Mermaid);
assert!(
pending_mermaid_asset.is_none(),
"empty Mermaid blocks should not trigger a render"
);
}
other => panic!("expected code block, got {other:?}"),
}
});
})
}
#[test]
fn test_non_parseable_mermaid_block_lays_out_as_code_block() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let block = mermaid_code_block("echo hi\n");
let (item, _) =
layout_mermaid_block_for_test(block, &text_layout, mermaid_layout_options(), ctx)
.expect("layout should succeed");
// On first sight, the asset is still loading. We defer the Mermaid-diagram
// path until the render either succeeds or fails, so the block should lay out
// as a code block with the asset source attached so the view layer can watch it.
match item {
BlockItem::RunnableCodeBlock {
code_block_type,
pending_mermaid_asset,
..
} => {
assert_eq!(code_block_type, CodeBlockType::Mermaid);
assert!(
pending_mermaid_asset.is_some(),
"a freshly-seen Mermaid source should schedule an asset load"
);
}
other => panic!("expected code block, got {other:?}"),
}
});
})
}
#[test]
fn test_invalid_mermaid_block_stays_as_code_block_after_load_fails() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let contents = "echo hi\n";
let asset_source = mermaid_asset_source(contents);
// Drive the asset load to completion (it should fail, since `echo hi` isn't
// valid Mermaid).
let pending = app.read(|ctx| {
let asset_cache = AssetCache::as_ref(ctx);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
_ => None,
}
});
if let Some(future) = pending {
future.await;
}
app.read(|ctx| {
let asset_cache = AssetCache::as_ref(ctx);
assert!(
matches!(
asset_cache.load_asset::<ImageType>(asset_source.clone()),
AssetState::FailedToLoad(_)
),
"Mermaid render for invalid source should have failed"
);
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let block = mermaid_code_block(contents);
let (item, _) =
layout_mermaid_block_for_test(block, &text_layout, mermaid_layout_options(), ctx)
.expect("layout should succeed");
match item {
BlockItem::RunnableCodeBlock {
code_block_type,
pending_mermaid_asset,
..
} => {
assert_eq!(code_block_type, CodeBlockType::Mermaid);
// Once the asset load has failed we don't need to keep watching the
// asset handle; the state won't flip again until the source changes.
assert!(
pending_mermaid_asset.is_none(),
"no watcher is needed after a Mermaid render failure"
);
}
other => panic!("expected code block, got {other:?}"),
}
});
})
}
#[test]
fn test_valid_mermaid_block_lays_out_as_diagram_after_load() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let contents = "graph TD\nA[Start] --> B[Finish]\n";
let asset_source = mermaid_asset_source(contents);
// Drive the async Mermaid render to completion.
let pending = app.read(|ctx| {
let asset_cache = AssetCache::as_ref(ctx);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
_ => None,
}
});
if let Some(future) = pending {
future.await;
}
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let block = mermaid_code_block(contents);
let (item, _) =
layout_mermaid_block_for_test(block, &text_layout, mermaid_layout_options(), ctx)
.expect("layout should succeed");
assert!(
matches!(item, BlockItem::MermaidDiagram { .. }),
"expected MermaidDiagram block once the asset is loaded, got {item:?}"
);
});
})
}
#[test]
fn test_mermaid_block_skipped_when_render_disabled() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let block = mermaid_code_block("graph TD\nA --> B\n");
let options = RenderLayoutOptions {
render_mermaid_diagrams: false,
..Default::default()
};
let (item, _) = layout_mermaid_block_for_test(block, &text_layout, options, ctx)
.expect("layout should succeed");
// When Mermaid rendering is disabled globally, the block should lay out as a
// plain code block with no asset source attached.
match item {
BlockItem::RunnableCodeBlock {
pending_mermaid_asset,
..
} => {
assert!(pending_mermaid_asset.is_none());
}
other => panic!("expected code block, got {other:?}"),
}
});
})
}
#[test]
fn test_resolve_asset_source_relative_to_directory_uses_base_directory() {
let asset_source =
resolve_asset_source_relative_to_directory("diagram.png", Some(Path::new("/tmp/session")));
match asset_source {
AssetSource::LocalFile { path } => {
AssetSource::LocalFile { path, .. } => {
assert_eq!(Path::new(&path), Path::new("/tmp/session/diagram.png"));
}
source => panic!("expected local file asset source, got {source:?}"),
+23 -21
View File
@@ -11,31 +11,24 @@
//! optimizations rely on fast substring searches (like highly-optimized platform-specific
//! `memchr` implementations) that we can't use with a non-contiguous buffer.
use std::{borrow::Cow, future::Future};
use std::borrow::Cow;
use std::future::Future;
use anyhow::{Context, bail};
use rangemap::RangeSet;
use regex_automata::{
Anchored, Input, MatchError, MatchKind,
hybrid::{
BuildError, LazyStateID,
dfa::{Cache, DFA},
},
nfa::thompson,
util::syntax::Config,
};
use regex_automata::hybrid::dfa::{Cache, DFA};
use regex_automata::hybrid::{BuildError, LazyStateID};
use regex_automata::nfa::thompson;
use regex_automata::util::syntax::Config;
use regex_automata::{Anchored, Input, MatchError, MatchKind};
use string_offset::CharOffset;
use sum_tree::SumTree;
use string_offset::CharOffset;
use super::buffer::Buffer;
use super::cursor::BufferCursor;
use super::text::{BufferSummary, BufferText};
use crate::search::RestorableSearchResults;
use super::{
buffer::Buffer,
cursor::BufferCursor,
text::{BufferSummary, BufferText},
};
#[cfg(test)]
#[path = "find_tests.rs"]
mod tests;
@@ -199,7 +192,7 @@ impl Engine {
buffer: &SumTree<BufferText>,
buffer_offset: CharOffset,
) -> anyhow::Result<Vec<Match>> {
galaxyui::r#async::block_on(self.find(buffer, buffer_offset))
galaxyui_core::r#async::block_on(self.find(buffer, buffer_offset))
}
/// Find all matches for this pattern in the given slice of content.
@@ -253,7 +246,7 @@ impl Engine {
log::warn!("Forward DFA found a match end, but reverse DFA did not find a start");
}
// Because we seeked to the left of the match end, move to the next item after the
// Because we sought to the left of the match end, move to the next item after the
// match to prevent an infinite loop.
buffer_cursor.next_char_position();
@@ -319,7 +312,16 @@ impl Engine {
}
} else if let Some(character) = cursor.char() {
let mut bytes = [0u8; 4];
for byte in character.encode_utf8(&mut bytes).bytes() {
let encoded = character.encode_utf8(&mut bytes).len();
let utf8 = &mut bytes[..encoded];
// The reverse DFA was compiled over the reversed byte stream, so a
// multi-byte character's bytes must be fed to it back-to-front.
// Single-byte (ASCII) characters are unaffected, which is why the bug
// only surfaced for non-ASCII (e.g. CJK) queries.
if matches!(direction, SearchDirection::Reverse) {
utf8.reverse();
}
for &byte in utf8.iter() {
state = dfa
.next_state(cache, state, byte)
.context("Couldn't advance to next state")?;
+76 -8
View File
@@ -1,19 +1,19 @@
use std::{iter, pin::pin, sync::Once};
use std::iter;
use std::pin::pin;
use std::sync::Once;
use futures_lite::future;
use galaxyui::App;
use itertools::Itertools;
use rangemap::RangeSet;
use sum_tree::SumTree;
use crate::content::{
buffer::Buffer,
cursor::BufferSumTree,
text::{BufferBlockStyle, BufferText, IndentBehavior},
};
use string_offset::CharOffset;
use sum_tree::SumTree;
use galaxyui_core::App;
use super::{Engine, Match, SearchConfig};
use crate::content::buffer::Buffer;
use crate::content::cursor::BufferSumTree;
use crate::content::text::{BufferBlockStyle, BufferText, IndentBehavior};
#[test]
fn test_search_inline_styles() {
@@ -140,6 +140,74 @@ fn test_end_of_buffer() {
});
}
#[test]
fn test_search_non_ascii() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"你好aaaaa\n再見你好",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
// ASCII literal search works regardless of the bug.
assert_matches(
buffer,
&SearchConfig::new("a"),
[
(3, 4, "a"),
(4, 5, "a"),
(5, 6, "a"),
(6, 7, "a"),
(7, 8, "a"),
],
);
// Multi-byte literal search must find every occurrence. Each CJK
// character is one `CharOffset` but several UTF-8 bytes; the reverse
// DFA pass that locates a match's start has to consume those bytes in
// reverse order, which is what this regression guards.
assert_matches(
buffer,
&SearchConfig::new("你好"),
[(1, 3, "你好"), (11, 13, "你好")],
);
// A multi-byte query mixing scripts also works.
assert_matches(buffer, &SearchConfig::new("好a"), [(2, 4, "好a")]);
// Regex over multi-byte content keeps working.
assert_matches(
buffer,
&SearchConfig::regex("你."),
[(1, 3, "你好"), (11, 13, "你好")],
);
});
});
}
#[test]
fn test_search_non_ascii_case_insensitive() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"CAFÉ café",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
// Case-insensitive matching over multi-byte characters (`É`/`é` are
// two UTF-8 bytes) must find both the upper- and lower-case forms,
// exercising the reverse-DFA byte order on case-folded input.
assert_matches(
buffer,
&SearchConfig::new("café").with_case_sensitive(false),
[(1, 5, "CAFÉ"), (6, 10, "café")],
);
});
});
}
#[test]
fn test_word_boundaries() {
App::test((), |mut app| async move {
@@ -4,13 +4,14 @@ use std::ops::Range;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle};
use rangemap::RangeSet;
use string_offset::CharOffset;
use crate::content::edit::EditDelta;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::version::BufferVersion;
use crate::content::{buffer::Buffer, text::LineCount};
use galaxyui_core::{AppContext, Entity, ModelContext, ModelHandle};
use super::anchor::{Anchor, AnchorSide};
use crate::content::buffer::Buffer;
use crate::content::edit::EditDelta;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::text::LineCount;
use crate::content::version::BufferVersion;
/// A model that tracks hidden line ranges independently of the buffer content.
/// This allows multiple editors to have different hidden line states for the same buffer.
+18 -24
View File
@@ -1,11 +1,11 @@
use std::collections::VecDeque;
use std::fmt::Write;
use std::ops::Range;
use std::{io, iter};
use anyhow::{Context, Result};
use galaxyui::text::point::Point;
use galaxyui::{AppContext, ModelContext, ModelHandle};
use html5ever::serialize;
use html5ever::{
QualName,
serialize::{Serialize, Serializer, TraversalScope},
};
use html5ever::serialize::{Serialize, Serializer, TraversalScope};
use html5ever::{QualName, serialize};
use itertools::Itertools;
use markdown_parser::{
CodeBlockText, FormattedIndentTextInline, FormattedTableAlignment, FormattedTaskList,
@@ -13,30 +13,23 @@ use markdown_parser::{
FormattedTextLine, OrderedFormattedIndentTextInline,
};
use markup5ever::ns;
use std::collections::VecDeque;
use std::fmt::Write;
use std::iter;
use std::{io, ops::Range};
use galaxyui::elements::{ListIndentLevel, ListNumbering};
use string_offset::CharOffset;
use crate::content::anchor::AnchorSide;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::version::BufferVersion;
use galaxyui_core::elements::{ListIndentLevel, ListNumbering};
use galaxyui_core::text::point::Point;
use galaxyui_core::{AppContext, ModelContext, ModelHandle};
use super::buffer::{
ActionWithSelectionDelta, EditOrigin, EditResult, StyledBufferBlocks, StyledBufferRun,
ActionWithSelectionDelta, Buffer, EditOrigin, EditResult, StyledBufferBlock,
StyledBufferBlocks, StyledBufferRun,
};
use super::core::{CoreEditorAction, CoreEditorActionType};
use super::text::{
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, FormattedTable,
TABLE_BLOCK_MARKDOWN_LANG,
};
use super::{
buffer::{Buffer, StyledBufferBlock},
text::TextStylesWithMetadata,
TABLE_BLOCK_MARKDOWN_LANG, TextStylesWithMetadata,
};
use crate::content::anchor::AnchorSide;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::version::BufferVersion;
/// A Markdown format to serialize a [`Buffer`] into.
#[derive(Clone, Copy)]
@@ -559,9 +552,10 @@ impl TextStylesWithMetadata {
}
}
use crate::content::buffer::{BufferEvent, ShouldAutoscroll, ToBufferCharOffset};
use markdown_parser::FormattedTextDelta;
use crate::content::buffer::{BufferEvent, ShouldAutoscroll, ToBufferCharOffset};
impl Buffer {
pub(super) fn replace_with_formatted_text(
&mut self,
+120 -7
View File
@@ -5,15 +5,12 @@ use markdown_parser::{compute_formatted_text_delta, parse_markdown};
use serde_yaml::Value;
use string_offset::CharOffset;
use vec1::Vec1;
use crate::content::{
buffer::{
Buffer, BufferEditAction, EditOrigin, StyledBlockBoundaryBehavior, tests::TestEmbeddedItem,
},
text::{IndentBehavior, TABLE_BLOCK_MARKDOWN_LANG},
};
use galaxyui_core::{App, ReadModel};
use super::MarkdownStyle;
use crate::content::buffer::tests::TestEmbeddedItem;
use crate::content::buffer::{Buffer, BufferEditAction, EditOrigin, StyledBlockBoundaryBehavior};
use crate::content::text::{IndentBehavior, TABLE_BLOCK_MARKDOWN_LANG};
#[test]
fn test_export_normalizes_code_languages() {
@@ -361,6 +358,122 @@ fn test_table_markdown_export_escapes_pipe_characters() {
});
}
#[test]
fn test_url_link_display_text_round_trip_is_stable() {
App::test((), |mut app| async move {
let original =
"[https://example.com/index.html#section](https://example.com/index.html#section)";
// After the first save, `.` and `#` in the display text are escaped.
// The URL in `(...)` is written verbatim — no escaping.
let expected_escaped = "[https://example\\.com/index\\.html\\#section](https://example.com/index.html#section)";
let (buffer, _) = Buffer::mock_from_markdown(
original,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let after_first = app.read_model(&buffer, |buffer, _| buffer.markdown());
assert_eq!(
after_first, expected_escaped,
"first save should escape special chars in display text"
);
let (buffer2, _) = Buffer::mock_from_markdown(
&after_first,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let after_second = app.read_model(&buffer2, |buffer, _| buffer.markdown());
assert_eq!(
after_second, expected_escaped,
"second round-trip should be stable"
);
let (buffer3, _) = Buffer::mock_from_markdown(
&after_second,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let after_third = app.read_model(&buffer3, |buffer, _| buffer.markdown());
assert_eq!(
after_third, expected_escaped,
"third round-trip should be stable"
);
// Plain text should be the clean, unescaped URL — no backslashes.
let plain_text = app.read_model(&buffer3, |buffer, _| buffer.text().as_str().to_string());
assert_eq!(plain_text, "https://example.com/index.html#section");
});
}
#[test]
fn test_markdown_escapes_punctuation() {
App::test((), |mut app| async move {
// markdown() escapes special chars.
let markdown = "Here's a markdown comment.\n";
let (buffer, _) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let escaped = app.read_model(&buffer, |buffer, _| buffer.markdown());
assert!(
escaped.contains("\\."),
"expected escaped periods, got: {escaped}"
);
});
}
#[test]
fn test_markdown_unescaped_does_not_escape_punctuation() {
App::test((), |mut app| async move {
// markdown_unescaped() should not add backslashes before periods.
let markdown = "Here's a markdown comment.\n";
let (buffer, _) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let unescaped = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert!(
!unescaped.contains("\\."),
"expected no escaped periods, got: {unescaped}"
);
assert!(
unescaped.contains("comment."),
"expected unescaped period, got: {unescaped}"
);
});
}
#[test]
fn test_markdown_unescaped_preserves_urls() {
App::test((), |mut app| async move {
// markdown_unescaped() should not escape characters inside URLs.
let markdown = "Check out https://www.example.com/path\n";
let (buffer, _) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let unescaped = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert!(
!unescaped.contains("\\/"),
"expected no escaped slashes, got: {unescaped}"
);
assert!(
unescaped.contains("https://www.example.com/path"),
"expected URL preserved, got: {unescaped}"
);
});
}
#[test]
fn test_image_with_content_html_serialization() {
App::test((), |mut app| async move {
+50 -30
View File
@@ -1,23 +1,19 @@
use std::{
hash::{DefaultHasher, Hash, Hasher},
sync::Arc,
};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use bytes::Bytes;
use galaxyui::{
AppContext, SingletonEntity,
assets::asset_cache::{AssetCache, AssetSource, AssetState, AsyncAssetId, AsyncAssetType},
image_cache::ImageType,
units::{IntoPixels, Pixels},
use galaxyui_core::assets::asset_cache::{
AssetCache, AssetSource, AssetState, AsyncAssetId, AsyncAssetType,
};
use mermaid_to_svg::MermaidTheme;
use galaxyui_core::image_cache::ImageType;
use galaxyui_core::units::{IntoPixels, Pixels};
use galaxyui_core::{AppContext, SingletonEntity};
use crate::render::{
layout::TextLayout,
model::{BlockSpacing, ImageBlockConfig},
};
use crate::render::layout::TextLayout;
use crate::render::model::{BlockSpacing, ImageBlockConfig};
const DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER: f32 = 10.0;
const FAILED_MERMAID_HEIGHT_LINE_MULTIPLIER: f32 = 2.0;
struct MermaidDiagramAsset;
@@ -27,7 +23,7 @@ pub fn mermaid_asset_source(source: &str) -> AssetSource {
let source = source.to_string();
let mut hasher = DefaultHasher::new();
source.hash(&mut hasher);
let id = format!("light:{:x}", hasher.finish());
let id = format!("configured:{:x}", hasher.finish());
let fetch_source = source.clone();
AssetSource::Async {
@@ -35,7 +31,7 @@ pub fn mermaid_asset_source(source: &str) -> AssetSource {
fetch: Arc::new(move || {
let source = fetch_source.clone();
Box::pin(async move {
mermaid_to_svg::render_mermaid_to_svg(&source, Some(&MermaidTheme::light()))
mermaid_to_svg::render_mermaid_to_svg(&source, None)
.map(|svg| Bytes::from(svg.into_bytes()))
.map_err(Into::into)
})
@@ -50,22 +46,42 @@ pub fn mermaid_diagram_layout(
app: &AppContext,
) -> (AssetSource, ImageBlockConfig) {
let asset_source = mermaid_asset_source(source);
let max_width = layout.max_width() - spacing.x_axis_offset();
let default_height = layout.rich_text_styles().base_line_height()
* DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER.into_pixels();
let (width, height) =
mermaid_diagram_size(&asset_source, max_width, app).unwrap_or((max_width, default_height));
let config = mermaid_diagram_config(&asset_source, layout, spacing, app);
(
asset_source,
ImageBlockConfig {
width,
height,
spacing,
},
)
(asset_source, config)
}
fn mermaid_diagram_config(
asset_source: &AssetSource,
layout: &TextLayout,
spacing: BlockSpacing,
app: &AppContext,
) -> ImageBlockConfig {
let max_width = layout.max_width() - spacing.x_axis_offset();
let (width, height) = mermaid_diagram_size(asset_source, max_width, app).unwrap_or_else(|| {
let height = layout.rich_text_styles().base_line_height()
* mermaid_diagram_fallback_height_line_multiplier(asset_source, app).into_pixels();
(max_width, height)
});
ImageBlockConfig {
width,
height,
spacing,
}
}
fn mermaid_diagram_fallback_height_line_multiplier(
asset_source: &AssetSource,
app: &AppContext,
) -> f32 {
let asset_cache = AssetCache::as_ref(app);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::FailedToLoad(_) => FAILED_MERMAID_HEIGHT_LINE_MULTIPLIER,
AssetState::Loading { .. } | AssetState::Loaded { .. } | AssetState::Evicted => {
DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER
}
}
}
fn mermaid_diagram_size(
asset_source: &AssetSource,
max_width: Pixels,
@@ -85,7 +101,11 @@ fn mermaid_diagram_size(
if intrinsic_width <= 0. || intrinsic_height <= 0. {
return None;
}
let width = Pixels::new(max_width.as_f32().min(intrinsic_width));
let width = max_width;
let height = Pixels::new(width.as_f32() * intrinsic_height / intrinsic_width);
Some((width, height))
}
#[cfg(test)]
#[path = "mermaid_diagram_tests.rs"]
mod tests;
@@ -0,0 +1,102 @@
use galaxyui_core::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui_core::image_cache::ImageType;
use galaxyui_core::text_layout::LayoutCache;
use galaxyui_core::{App, SingletonEntity};
use super::*;
use crate::render::layout::TextLayout;
use crate::render::model::test_utils::TEST_STYLES;
fn mermaid_block_spacing() -> BlockSpacing {
TEST_STYLES.block_spacings.from_block_style(
&crate::content::text::BufferBlockStyle::CodeBlock {
code_block_type: crate::content::text::CodeBlockType::Mermaid,
},
)
}
#[test]
fn loading_mermaid_layout_uses_default_height() {
App::test((), |app| async move {
app.read(|ctx| {
let source = "graph TD\nA[Start] --> B[Finish]\n";
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let (_asset_source, config) =
mermaid_diagram_layout(source, &text_layout, mermaid_block_spacing(), ctx);
let expected_height = TEST_STYLES.base_line_height()
* DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER.into_pixels();
assert!((config.height.as_f32() - expected_height.as_f32()).abs() < 0.5);
});
})
}
#[test]
fn mermaid_asset_source_renders_frontmatter_formatting_directives() {
let source = r##"---
config:
theme: base
themeVariables:
primaryColor: "#ff0000"
fontFamily: Inter
fontSize: 18px
flowchart:
curve: linear
nodeSpacing: 80
---
flowchart TD
A[Start] --> B[Done]
"##;
let AssetSource::Async { fetch, .. } = mermaid_asset_source(source) else {
panic!("expected Mermaid diagrams to be async assets");
};
let bytes = match futures_lite::future::block_on(fetch()) {
Ok(bytes) => bytes,
Err(error) => panic!("expected frontmatter directives to render: {error:#}"),
};
let svg = match String::from_utf8(bytes.to_vec()) {
Ok(svg) => svg,
Err(error) => panic!("expected Mermaid SVG to be valid UTF-8: {error}"),
};
assert!(svg.contains("<svg "));
assert!(svg.contains(r##"fill="#ff0000""##));
assert!(svg.contains(r#"font-family="Inter""#));
}
#[test]
fn failed_mermaid_layout_uses_compact_height() {
App::test((), |app| async move {
app.read(|ctx| {
let asset_source = AssetSource::Raw {
id: "missing-mermaid-test-asset".to_string(),
};
let asset_cache = AssetCache::as_ref(ctx);
assert!(matches!(
asset_cache.load_asset::<ImageType>(asset_source.clone()),
AssetState::FailedToLoad(_)
));
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let config =
mermaid_diagram_config(&asset_source, &text_layout, mermaid_block_spacing(), ctx);
let expected_height = TEST_STYLES.base_line_height()
* FAILED_MERMAID_HEIGHT_LINE_MULTIPLIER.into_pixels();
assert!((config.height.as_f32() - expected_height.as_f32()).abs() < 0.5);
});
})
}
+4 -7
View File
@@ -1,12 +1,9 @@
use string_offset::CharOffset;
use sum_tree::{Cursor, SeekBias};
use super::buffer::Buffer;
use super::text::{BlockCount, BufferBlockStyle, BufferText};
use crate::content::text::BlockType;
use string_offset::CharOffset;
use super::{
buffer::Buffer,
text::{BlockCount, BufferBlockStyle, BufferText},
};
#[cfg(test)]
#[path = "outline_tests.rs"]
@@ -82,7 +79,7 @@ impl Iterator for BlockOutlines<'_> {
let end_count = self.count + 1;
self.cursor.seek(&end_count, SeekBias::Left);
// We seeked to the start of the next block, so it's where we start on the next pass.
// We sought to the start of the next block, so it's where we start on the next pass.
self.count = end_count;
Some(BlockOutline {
start: start_offset,
+6 -8
View File
@@ -1,13 +1,11 @@
use itertools::Itertools;
use crate::content::{
buffer::Buffer,
outline::BlockOutline,
selection_model::BufferSelectionModel,
text::{BlockType, BufferBlockStyle, IndentBehavior, TextStyles},
};
use galaxyui::App;
use string_offset::CharOffset;
use galaxyui_core::App;
use crate::content::buffer::Buffer;
use crate::content::outline::BlockOutline;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::text::{BlockType, BufferBlockStyle, IndentBehavior, TextStyles};
#[test]
fn test_no_blocks() {
+5 -4
View File
@@ -5,11 +5,12 @@
use anyhow::anyhow;
use galaxyui::text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy};
use string_offset::CharOffset;
use galaxyui_core::text::TextBuffer;
use galaxyui_core::text::point::Point;
use galaxyui_core::text::word_boundaries::WordBoundariesPolicy;
use super::{
buffer::{Buffer, ToBufferCharOffset, ToBufferPoint},
cursor::BufferCursor,
};
use super::buffer::{Buffer, ToBufferCharOffset, ToBufferPoint};
use super::cursor::BufferCursor;
#[cfg(test)]
#[path = "segmentation_tests.rs"]
@@ -2,16 +2,14 @@ use galaxy_core::features::FeatureFlag;
use itertools::Itertools;
use markdown_parser::parse_markdown;
use string_offset::CharOffset;
use galaxyui_core::App;
use galaxyui_core::text::TextBuffer;
use galaxyui_core::text::point::Point;
use galaxyui_core::text::word_boundaries::WordBoundariesPolicy;
use crate::content::{
buffer::{Buffer, EditOrigin},
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use galaxyui::{
App,
text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy},
};
use crate::content::buffer::{Buffer, EditOrigin};
use crate::content::selection_model::BufferSelectionModel;
use crate::content::text::IndentBehavior;
#[test]
fn test_forward_iteration() {
+1 -2
View File
@@ -1,6 +1,5 @@
use vec1::Vec1;
use string_offset::CharOffset;
use vec1::Vec1;
use super::anchor::{Anchor, AnchorSide, Anchors};
+6 -7
View File
@@ -4,13 +4,12 @@ use galaxyui::{AppContext, Entity, ModelHandle};
use itertools::Itertools;
use string_offset::CharOffset;
use vec1::{Vec1, vec1};
use galaxyui_core::{AppContext, Entity, ModelHandle};
use crate::content::{
anchor::{Anchor, AnchorSide, AnchorUpdate, Anchors},
buffer::{Buffer, SelectionOffsets, ToBufferPoint},
selection::{Selection, SelectionSet},
text::{BlockType, TextStylesWithMetadata},
};
use crate::content::anchor::{Anchor, AnchorSide, AnchorUpdate, Anchors};
use crate::content::buffer::{Buffer, SelectionOffsets, ToBufferPoint};
use crate::content::selection::{Selection, SelectionSet};
use crate::content::text::{BlockType, TextStylesWithMetadata};
/// A snapshot of the selection state. This includes all data reported by [`BufferEvent::SelectionChanged`].
#[derive(PartialEq, Eq)]
@@ -309,7 +308,7 @@ impl BufferSelectionModel {
}
/// Validate the buffer content with this selection model's anchors.
pub fn validate_buffer(&self, ctx: &impl galaxyui::ModelAsRef) {
pub fn validate_buffer(&self, ctx: &impl galaxyui_core::ModelAsRef) {
self.buffer.as_ref(ctx).validate(&self.anchors);
}
+21 -17
View File
@@ -1,7 +1,10 @@
use crate::render::model::{
EmbeddedItem,
table_offset_map::{TableCellOffsetMap, TableOffsetMap},
};
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::{self, Display};
use std::hash::{Hash, Hasher};
use std::ops::{Add, AddAssign, BitXor, BitXorAssign, Range, Sub, SubAssign};
use std::sync::{Arc, OnceLock};
use arrayvec::ArrayString;
use enum_iterator::Sequence;
use galaxy_core::features::FeatureFlag;
@@ -14,13 +17,13 @@ use galaxyui::{
};
use lazy_static::lazy_static;
pub use markdown_parser::markdown_parser::TABLE_BLOCK_MARKDOWN_LANG;
use markdown_parser::markdown_parser::{
CODE_BLOCK_DEFAULT_MARKDOWN_LANG, EMBED_BLOCK_MARKDOWN_LANG, RUNNABLE_BLOCK_MARKDOWN_LANG,
};
use markdown_parser::weight::CustomWeight;
use markdown_parser::{
CodeBlockText, FormattedImage, FormattedTextLine, FormattedTextStyles, Hyperlink,
markdown_parser::{
CODE_BLOCK_DEFAULT_MARKDOWN_LANG, EMBED_BLOCK_MARKDOWN_LANG, RUNNABLE_BLOCK_MARKDOWN_LANG,
},
parse_markdown,
weight::CustomWeight,
};
pub use markdown_parser::{
FormattedTable, FormattedTableAlignment, FormattedTextFragment, FormattedTextInline,
@@ -28,18 +31,19 @@ pub use markdown_parser::{
use pathfinder_color::ColorU;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::{
borrow::Cow,
collections::HashSet,
fmt::{self, Display},
hash::{Hash, Hasher},
ops::{Add, AddAssign, BitXor, BitXorAssign, Range, Sub, SubAssign},
sync::{Arc, OnceLock},
};
use string_offset::{ByteOffset, CharOffset, impl_offset};
use sum_tree::{Cursor, SeekBias, SumTree};
use galaxyui_core::AppContext;
use galaxyui_core::elements::ListIndentLevel;
use galaxyui_core::fonts::{Properties, Style, Weight};
use galaxyui_core::text::BlockHeaderSize as HeaderSize;
use galaxyui_core::text::point::Point;
use super::{buffer::Buffer, core::CursorType, markdown::MarkdownStyle};
use super::buffer::Buffer;
use super::core::CursorType;
use super::markdown::MarkdownStyle;
use crate::render::model::EmbeddedItem;
use crate::render::model::table_offset_map::{TableCellOffsetMap, TableOffsetMap};
/// Collect the plain text from a `FormattedTextInline` (a slice of fragments).
pub fn inline_to_text(inline: &[FormattedTextFragment]) -> String {
+2 -4
View File
@@ -1,8 +1,6 @@
use galaxyui::fonts::Weight;
use markdown_parser::CodeBlockText;
use markdown_parser::{CodeBlockText, FormattedTable};
use galaxy_core::features::FeatureFlag;
use markdown_parser::FormattedTable;
use galaxyui_core::fonts::Weight;
use super::{
BufferBlockItem, BufferTextStyle, CodeBlockType, MarkdownStyle, TextStyles,
+4 -4
View File
@@ -1,11 +1,11 @@
use std::{collections::VecDeque, time::Duration};
use std::collections::VecDeque;
use std::time::Duration;
use galaxy_util::content_version::ContentVersion;
use instant::Instant;
use crate::render::model::RenderedSelectionSet;
use super::core::{CoreEditorAction, ReplacementRange};
use crate::render::model::RenderedSelectionSet;
/// Threshold to separate two non-atomic undo items.
const UNDO_REDO_TIMER: Duration = Duration::from_millis(500);
@@ -338,5 +338,5 @@ impl UndoStack {
}
#[cfg(test)]
#[path = "undo_test.rs"]
#[path = "undo_tests.rs"]
pub mod tests;
@@ -1,6 +1,5 @@
use crate::render::model::RenderedSelection;
use super::*;
use crate::render::model::RenderedSelection;
#[test]
fn test_version_match_initial() {
+2 -5
View File
@@ -1,12 +1,9 @@
use pathfinder_color::ColorU;
use sum_tree::SumTree;
use crate::content::{
cursor::BufferSumTree,
text::{BlockLineBreakBehavior, BlockType, ColorMarker},
};
use super::text::{BufferBlockStyle, BufferSummary, BufferText, MarkerDir};
use crate::content::cursor::BufferSumTree;
use crate::content::text::{BlockLineBreakBehavior, BlockType, ColorMarker};
#[cfg(test)]
#[path = "validation_tests.rs"]
@@ -1,11 +1,10 @@
use crate::content::{
cursor::BufferSumTree,
text::{
BlockHeaderSize, BufferBlockItem, BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir,
},
};
use galaxyui::elements::ListIndentLevel;
use sum_tree::SumTree;
use galaxyui_core::elements::ListIndentLevel;
use crate::content::cursor::BufferSumTree;
use crate::content::text::{
BlockHeaderSize, BufferBlockItem, BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir,
};
#[test]
#[should_panic(