Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
use std::{
collections::HashMap,
sync::{Arc, Weak},
};
use sum_tree::SumTree;
use string_offset::CharOffset;
use super::text::BufferText;
#[cfg(test)]
#[path = "anchor_test.rs"]
mod test;
/// Handle to a particular anchor. As long as there is an active handle, the
/// anchor will be kept in sync with text edits. Once all handles are dropped,
/// the anchor is lazily disposed.
#[derive(Debug, Clone, PartialEq)]
pub struct Anchor {
id: AnchorId,
/// Reference to keep this anchor alive. See [`AnchorReference`].
reference: Arc<AnchorReference>,
}
/// Empty type for keeping anchor references alive.
///
/// We use `Arc<AnchorReference>` to implement reference-counting for anchors.
/// External anchor handles ([`Anchor`]) have strong references to their
/// `AnchorReference`, so it lives as long as there is an existing anchor.
/// Internally, we store a `Weak<AnchorReference>`, solely to be able to check
/// the strong reference count and clean up unused anchors.
///
/// ### Why not count references directly?
/// We could implement our own reference counting using an atomic integer. However,
/// in order to pass that atomic integer to callers and create [`Anchor`] handles,
/// we'd have to wrap it in an `Arc` or similar anyways - [`Anchor`] and [`Anchors`]
/// need a safe way to refer to the same memory!
///
/// ### Why not wrap [`AnchorState`] in an [`Arc`]?
/// We could have [`Anchor`]s strongly own their state, while keeping a [`Weak`]
/// reference here. When updating anchors, we'd filter out any that can't
/// be upgraded to a strong reference. However, we still need mutable access to
/// the character offset of each anchor - that's simpler if [`Anchors`] owns
/// all the mutable state. In addition, we want all anchor dereferencing to
/// go through the model, so that the UI framework enforces consistency around
/// when updates are applied. If [`Anchor`]s could be resolved without a model
/// handle, we don't know what state of the world they see.
type AnchorReference = ();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct AnchorId(usize);
#[derive(Debug, Clone, Copy)]
pub struct AnchorUpdate {
pub start: CharOffset,
pub old_character_count: usize,
pub new_character_count: usize,
pub clamp: bool,
}
/// Used to tie-break when an update happens exactly at the position of the anchor.
/// When set to AnchorSide::Left, updates happening at the exact position won't shift
/// the anchor offset. When set to AnchorSide::Right, they will shift the offset.
///
/// For example, there is an anchor at CharOffset(2) with an incoming update update(
/// start=2, old_char_count=0, new_char_count=1). If the anchor has AnchorSide::Left, it
/// will stay at CharOffset(2). If the anchor has AnchorSide::Right, it will shift to CharOffset(3).
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub enum AnchorSide {
Left,
Right,
}
/// Internal state for a specific anchor.
struct AnchorState {
/// Weak reference to detect when this anchor is no longer strongly
/// referenced and should be removed. See [`AnchorReference`].
live: Weak<AnchorReference>,
/// The current character offset that this anchor points to.
offset: CharOffset,
side: AnchorSide,
}
/// Component of the buffer model that tracks relative anchors into the content.
///
/// Unlike point-in-time character offsets, anchors shift as text is added or
/// removed around them.
pub(crate) struct Anchors {
next_id: usize,
anchors: HashMap<AnchorId, AnchorState>,
}
impl Anchors {
pub fn new() -> Anchors {
Self {
next_id: 0,
anchors: HashMap::new(),
}
}
/// Update an existing anchor to a new offset.
pub fn update_anchor(&mut self, anchor: &Anchor, offset: CharOffset) {
if let Some(anchor) = self.anchors.get_mut(&anchor.id) {
anchor.offset = offset;
}
}
/// Create a new anchor starting at the given offset.
pub fn create_anchor(&mut self, offset: CharOffset, side: AnchorSide) -> Anchor {
let id = AnchorId(self.next_id);
self.next_id = self.next_id.wrapping_add(1);
let reference = Arc::new(());
self.anchors.insert(
id,
AnchorState {
live: Arc::downgrade(&reference),
offset,
side,
},
);
Anchor { id, reference }
}
/// Update all live anchors to reflect replacing `old_character_count`
/// characters at `start_offset` with `new_character_count` characters.
///
/// This will also dispose of any anchors that are no longer live if clamp is false.
/// If clamp is set to true, the anchors in an deleted range will be clamped to the new
/// end instead.
pub fn update(&mut self, update: AnchorUpdate) {
let AnchorUpdate {
start,
old_character_count,
new_character_count,
clamp,
} = update;
let old_end = start + old_character_count;
let new_end = start + new_character_count;
self.anchors.retain(|_, state| {
if !state.is_live() {
return false;
}
// There are 3 cases for an anchor's location relative to an edit:
// 1. It's after the original end location, and needs to be shifted by the edit delta.
// 2. It's within the deleted text, and should be removed.
// 3. It's not affected by the edit (either before it or within overwritten text).
//
// Consider the following net insertion over d..e (ignore spaces):
// a b c|d e|f g h => a b c|x y z|f g h
// ^ ^ ^ => ^ ^ ^
// 1 2 3 => 1 2 3
// * Anchor 1 should not move.
// * Anchor 2 should not move, even though it's now between x..y
// instead of d..e
// * Anchor 3 moves up by 1 character
//
// Likewise, consider a net deletion, going from def to z:
// a b c|d e f|g h i => a b c|z|g h i
// ^ ^ ^ ^ => ^ ^ ^
// 1 2 3 4 => 1 2 4
// * Again, anchor 1 does not move
// * Anchor 2 does not move - it's within the affected range, now after
// z instead of d, but was not deleted.
// * Anchor 3 referred to content that no longer exists at all, so it
// is removed.
// * Anchor 4 moves down by 2 characters, since it's fully after the
// affected range.
if state.offset > old_end
|| (matches!(state.side, AnchorSide::Right) && state.offset == old_end)
{
// Overall, we need to adjust the offset by the difference between
// the old and new lengths: state.offset += new_end - range.end.
// Since we're dealing with unsigned integers, regrouping as
// state.offset = (state.offset + new_end) - range.end avoids
// underflow. It will overflow if state.offset + new_end is
// greater than usize::MAX, but we do not expect that in practice.
state.offset = (state.offset + new_end) - old_end;
true
} else {
// We want to clamp instead of invalidate anchors if:
// 1) Clamp is set to true.
// 2) If an anchor is exactly at the old_end offset and is pegged to the
// left side, we should still retain the anchor.
if clamp && state.offset > new_end
|| (state.offset == old_end
&& state.offset > new_end
&& matches!(state.side, AnchorSide::Left))
{
state.offset = new_end;
return true;
}
// If we're in this branch, the anchor is either unaffected or
// should be removed.
state.offset <= new_end
}
});
}
/// Resolve an anchor to the character offset it currently points to.
pub fn resolve(&self, anchor: &Anchor) -> Option<CharOffset> {
// The anchor may have been removed by an edit. However, we don't need
// to check liveness, because the fact that an anchor exists to call this
// function means that it is live.
self.anchors.get(&anchor.id).map(|state| state.offset)
}
/// Validates all anchors against the content they reference.
pub fn validate(&self, content: &SumTree<BufferText>) {
let content_length: CharOffset = content.extent();
for (id, anchor) in self.anchors.iter() {
if anchor.is_live() {
assert!(
anchor.offset <= content_length,
"{id:?} has offset {}, but buffer length is {content_length}",
anchor.offset
);
}
}
}
}
impl AnchorState {
fn is_live(&self) -> bool {
self.live.strong_count() > 0
}
}
impl Default for Anchors {
fn default() -> Self {
Self::new()
}
}
+263
View File
@@ -0,0 +1,263 @@
use std::cmp::Ordering;
use sum_tree::SumTree;
use warpui::App;
use super::{AnchorSide, Anchors};
use crate::content::{
anchor::{Anchor, AnchorUpdate},
buffer::Buffer,
cursor::BufferSumTree,
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use string_offset::CharOffset;
#[test]
fn test_anchor_cleanup() {
let mut anchors = Anchors::new();
let a = anchors.create_anchor(3.into(), AnchorSide::Right);
let b = anchors.create_anchor(4.into(), AnchorSide::Right);
// Both anchors are live at this point.
assert_eq!(anchors.anchors.len(), 2);
// If an anchor is dropped, it is garbage-collected.
drop(a);
anchors.update(AnchorUpdate {
start: CharOffset::zero(),
old_character_count: 0,
new_character_count: 0,
clamp: false,
});
assert_eq!(anchors.anchors.len(), 1);
// However, the other anchor should still resolve.
assert_eq!(anchors.resolve(&b), Some(4.into()));
// If an anchor is cloned, the clone keeps it alive.
let b2 = b.clone();
drop(b);
anchors.update(AnchorUpdate {
start: CharOffset::zero(),
old_character_count: 0,
new_character_count: 0,
clamp: false,
});
assert_eq!(anchors.resolve(&b2), Some(4.into()));
}
#[test]
fn test_insert() {
let mut anchors = Anchors::new();
let before = anchors.create_anchor(3.into(), AnchorSide::Right);
let cursor = anchors.create_anchor(6.into(), AnchorSide::Right);
let cursor_left = anchors.create_anchor(6.into(), AnchorSide::Left);
let after = anchors.create_anchor(9.into(), AnchorSide::Right);
// Simulate typing a character at the cursor.
anchors.update(AnchorUpdate {
start: CharOffset::from(6),
old_character_count: 0,
new_character_count: 1,
clamp: false,
});
// The anchor before the edit is unaffected.
assert_eq!(anchors.resolve(&before), Some(3.into()));
// The anchor _at_ the cursor increases, to be at the new cursor location.
assert_eq!(anchors.resolve(&cursor), Some(7.into()));
// The anchor _at_ the cursor with AnchorSide::Left stays at its old location.
assert_eq!(anchors.resolve(&cursor_left), Some(6.into()));
// The anchor after the cursor is also shifted down.
assert_eq!(anchors.resolve(&after), Some(10.into()));
// We should be able to type more text, and the anchors continue to update.
anchors.update(AnchorUpdate {
start: CharOffset::from(7),
old_character_count: 0,
new_character_count: 3,
clamp: false,
});
assert_eq!(anchors.resolve(&cursor), Some(10.into()));
assert_eq!(anchors.resolve(&after), Some(13.into()));
}
#[test]
fn test_backspace() {
let mut anchors = Anchors::new();
let before = anchors.create_anchor(3.into(), AnchorSide::Right);
let cursor = anchors.create_anchor(6.into(), AnchorSide::Right);
let after = anchors.create_anchor(9.into(), AnchorSide::Right);
// Simulate backspacing at the cursor.
anchors.update(AnchorUpdate {
start: CharOffset::from(5),
old_character_count: 1,
new_character_count: 0,
clamp: false,
});
// The anchor before the edit is unaffected.
assert_eq!(anchors.resolve(&before), Some(3.into()));
// The cursor and the anchor after it both shift by 1.
assert_eq!(anchors.resolve(&cursor), Some(5.into()));
assert_eq!(anchors.resolve(&after), Some(8.into()));
}
#[test]
fn test_invalidate_anchor() {
let mut anchors = Anchors::new();
let inside = anchors.create_anchor(4.into(), AnchorSide::Right);
let outside = anchors.create_anchor(3.into(), AnchorSide::Right);
let outside_anchor_right = anchors.create_anchor(5.into(), AnchorSide::Right);
let outside_anchor_left = anchors.create_anchor(5.into(), AnchorSide::Left);
// If we delete text including one of the anchors, it's invalidated.
anchors.update(AnchorUpdate {
start: CharOffset::from(3),
old_character_count: 2,
new_character_count: 0,
clamp: false,
});
assert_eq!(anchors.resolve(&inside), None);
// However, the anchor just before it is unaffected.
assert_eq!(anchors.resolve(&outside), Some(3.into()));
// The anchor on the right side is updated because it is equal to the old character range.
assert_eq!(anchors.resolve(&outside_anchor_right), Some(3.into()));
// The anchor on the left side should still be valid because it is equal to the old character range.
assert_eq!(anchors.resolve(&outside_anchor_left), Some(3.into()));
// If clamp is set to true, we want to clamp instead of invalidate anchors.
let inside = anchors.create_anchor(4.into(), AnchorSide::Right);
anchors.update(AnchorUpdate {
start: CharOffset::from(3),
old_character_count: 2,
new_character_count: 0,
clamp: true,
});
assert_eq!(anchors.resolve(&inside), Some(3.into()));
}
#[test]
fn test_update_anchor() {
let mut anchors = Anchors::new();
let anchor = anchors.create_anchor(4.into(), AnchorSide::Right);
anchors.update_anchor(&anchor, CharOffset::from(3));
assert_eq!(anchors.resolve(&anchor), Some(3.into()));
}
#[test]
#[should_panic(expected = "AnchorId(2) has offset 5, but buffer length is 4")]
fn test_validate_anchor_out_of_bounds() {
let mut anchors = Anchors::new();
let _valid_anchor = anchors.create_anchor(2.into(), AnchorSide::Right);
// An invalid, but dead, anchor.
let _ = anchors.create_anchor(100.into(), AnchorSide::Right);
let _invalid_anchor = anchors.create_anchor(5.into(), AnchorSide::Right);
let mut tree = SumTree::new();
tree.append_str("abcd");
anchors.validate(&tree);
}
#[test]
fn test_validate_anchors_ok() {
let mut anchors = Anchors::new();
let _valid_anchor = anchors.create_anchor(2.into(), AnchorSide::Right);
// An invalid, but dead, anchor.
let _ = anchors.create_anchor(100.into(), AnchorSide::Right);
let mut tree = SumTree::new();
tree.append_str("abcd");
// This should not panic.
anchors.validate(&tree);
}
#[test]
fn test_anchor_comparison() {
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| {
buffer.edit_internal_first_selection(
CharOffset::zero()..CharOffset::zero(),
"some text",
Default::default(),
selection.clone(),
ctx,
);
let first = selection.update(ctx, |selection, _| {
selection.create_anchor(CharOffset::from(1), AnchorSide::Right)
});
// Anchors should be equal to themselves.
assert_eq!(
first.cmp(&first.clone(), selection.as_ref(ctx)),
Some(Ordering::Equal)
);
// Anchors should be equal to other anchors with the same offset.
let first2 = selection.update(ctx, |selection, _| {
selection.create_anchor(CharOffset::from(1), AnchorSide::Right)
});
assert_ne!(first.id, first2.id);
assert_eq!(
first.cmp(&first2, selection.as_ref(ctx)),
Some(Ordering::Equal)
);
// Unequal anchors compare by offset.
let second = selection.update(ctx, |selection, _| {
selection.create_anchor(CharOffset::from(5), AnchorSide::Right)
});
assert_eq!(
first.cmp(&second, selection.as_ref(ctx)),
Some(Ordering::Less)
);
assert_eq!(
second.cmp(&first, selection.as_ref(ctx)),
Some(Ordering::Greater)
);
// Invalid anchors do not compare - delete the range containing `second`.
buffer.edit_internal_first_selection(
CharOffset::from(3)..CharOffset::from(7),
"",
Default::default(),
selection.clone(),
ctx,
);
assert_eq!(second.cmp(&first, selection.as_ref(ctx)), None);
assert_eq!(first.cmp(&second, selection.as_ref(ctx)), None);
});
});
}
impl Anchor {
pub fn cmp(&self, other: &Anchor, selection: &BufferSelectionModel) -> Option<Ordering> {
if self.id == other.id {
Some(Ordering::Equal)
} else {
Some(
selection
.resolve_anchor(self)?
.cmp(&selection.resolve_anchor(other)?),
)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
use std::ops::Range;
use arrayvec::ArrayString;
use num_traits::SaturatingSub;
use pathfinder_color::ColorU;
use string_offset::CharOffset;
use sum_tree::{Cursor, Dimension, SeekBias, SumTree};
use super::text::{
BufferSummary, BufferText, ColorMarker, LinkCount, LinkMarker, SyntaxColorId,
TEXT_FRAGMENT_SIZE,
};
/// Cursor that provides utility function to traverse the buffer tree. Note that
/// this is always preferred over traversing buffer directly using a SumTree cursor given
/// the items could have different length (e.g. markers take 0 offset while text fragment
/// could take more than 1 offset).
#[derive(Clone, Debug)]
pub struct BufferCursor<'a, U: Dimension<'a, BufferSummary>> {
cursor: Cursor<'a, BufferText, CharOffset, U>,
offset: CharOffset,
}
impl<'a, U> BufferCursor<'a, U>
where
U: Dimension<'a, BufferSummary>,
{
pub fn new(cursor: Cursor<'a, BufferText, CharOffset, U>) -> Self {
Self {
cursor,
offset: CharOffset::zero(),
}
}
/// The current offset of the cursor.
pub fn offset(&self) -> CharOffset {
self.offset
}
/// Return the BufferText item at the given cursor position.
pub fn item(&self) -> Option<&'a BufferText> {
self.cursor.item()
}
/// Return the char at the given cursor position. This could be none
/// if the cursor is at a style marker. Note that this is different from
/// Self::item which will return the entire text fragment when cursor is on
/// one.
pub fn char(&self) -> Option<char> {
match &self.item() {
Some(BufferText::Text { fragment, .. }) => {
let cursor_offset = *self.cursor.seek_position();
let ix = self.offset - cursor_offset;
fragment.chars().nth(ix.as_usize())
}
Some(BufferText::BlockMarker { .. }) | Some(BufferText::Newline) => Some('\n'),
_ => None,
}
}
/// Move the cursor to the given charoffset and return the char at the cursor position.
/// This could be empty.
pub fn char_at(&mut self, offset: CharOffset) -> Option<char> {
self.seek_to_offset_after_markers(offset);
self.char()
}
pub fn start(&self) -> &U {
self.cursor.start()
}
/// Attempts to move to the next character position. If the next item has
/// zero character offset length, move to that item and keep the current character
/// offset.
///
/// This is different from Self::next as it will only increment the offset and not
/// move the cursor if it is in the middle of a text fragment.
pub fn next_char_position(&mut self) {
let end = self.cursor.end_seek_position();
let next_char_position = self.offset + 1;
if next_char_position >= end {
self.cursor.next();
self.offset = *self.cursor.seek_position();
} else {
self.offset = next_char_position;
}
}
/// Attempts to move to the previous character position. If the previous item has
/// zero character offset length, move to that item and keep the current character
/// offset.
///
/// This is different from Self::prev as it will only decrement the offset and not
/// move the cursor if it is in the middle of a text fragment.
pub fn prev_char_position(&mut self) {
let start = *self.cursor.seek_position();
let prev_char_position = self.offset.saturating_sub(&CharOffset::from(1));
if start > prev_char_position || self.offset == CharOffset::zero() {
self.cursor.prev();
// If we move into a text fragment, this makes sure we are not jumping
// to the start of that fragment.
self.offset = (*self.cursor.seek_position()).max(prev_char_position);
} else {
self.offset = prev_char_position;
}
}
/// Moves directly to the next buffer text item and updates the active offset.
pub fn next(&mut self) {
self.cursor.next();
self.offset = *self.cursor.seek_position();
}
pub fn prev_item(&self) -> Option<&'a BufferText> {
self.cursor.prev_item()
}
/// Place the cursor at the beginning of end before all the zero-width markers.
pub fn seek_to_offset_before_markers(&mut self, end: CharOffset) -> bool {
debug_assert!(end >= self.offset);
let found = self.cursor.seek(&end, SeekBias::Left);
if found && self.cursor.end_seek_position() == end {
self.cursor.next();
}
self.offset = end;
found || end == CharOffset::zero()
}
/// Place the cursor at the beginning of end after all the zero-width markers.
pub fn seek_to_offset_after_markers(&mut self, end: CharOffset) -> bool {
if end < self.offset {
return false;
}
debug_assert!(end >= self.offset);
self.offset = end;
self.cursor.seek(&end, SeekBias::Right)
}
/// Place the cursor at the beginning of end before all the zero-width markers.
/// Return a new SumTree with items from cursor up to but not including end.
pub fn slice_to_offset_before_markers(&mut self, end: CharOffset) -> SumTree<BufferText> {
debug_assert!(end >= self.offset);
let mut new_content = SumTree::new();
// If the current offset is in the middle of a text fragment, split the fragment and push
// the latter half to the SumTree.
if self.offset > *self.cursor.seek_position()
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let start_ix = self.offset - *self.cursor.seek_position();
let char_to_take = end - self.offset;
let new_fragment: String = fragment
.chars()
.skip(start_ix.as_usize())
.take(char_to_take.as_usize())
.collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
if end < self.cursor.end_seek_position() {
self.offset = end;
return new_content;
}
self.cursor.next();
}
let sliced = self.cursor.slice(&end, SeekBias::Left);
new_content.push_tree(sliced);
// If the end offset is in the middle of a text fragment, split the fragment and push
// the earlier half to the SumTree.
if self.cursor.end_seek_position() > end {
if let Some(BufferText::Text { fragment, .. }) = self.cursor.item() {
let ix = end - *self.cursor.seek_position();
let new_fragment: String = fragment.chars().take(ix.as_usize()).collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
}
} else if end > *self.cursor.seek_position() && end > CharOffset::zero() {
if let Some(item) = self.cursor.item() {
new_content.push(item.clone());
}
self.cursor.next();
}
self.offset = end;
new_content
}
/// Place the cursor at the beginning of end after all the zero-width markers.
/// Return a new SumTree with items from cursor up to but not including end.
pub fn slice_to_offset_after_markers(&mut self, end: CharOffset) -> SumTree<BufferText> {
debug_assert!(end >= self.offset);
let mut new_content = SumTree::new();
// If the current offset is in the middle of a text fragment, split the fragment and push
// the earlier half to the SumTree.
if self.offset > *self.cursor.seek_position()
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let ix = self.offset - *self.cursor.seek_position();
let char_to_take = end - self.offset;
let new_fragment: String = fragment
.chars()
.skip(ix.as_usize())
.take(char_to_take.as_usize())
.collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
if end < self.cursor.end_seek_position() {
self.offset = end;
return new_content;
}
self.cursor.next();
}
new_content.push_tree(self.cursor.slice(&end, SeekBias::Right));
// If the current offset is in the middle of a text fragment, split the fragment and push
// the latter half to the SumTree.
if *self.cursor.seek_position() < end
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let ix = end - *self.cursor.seek_position();
let new_fragment: String = fragment.chars().take(ix.as_usize()).collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
}
self.offset = end;
new_content
}
/// Return a new SumTree with all items after the current cursor.
pub fn suffix(&mut self) -> SumTree<BufferText> {
let mut new_content = SumTree::new();
if self.offset > *self.cursor.seek_position()
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let start_ix = self.offset - *self.cursor.seek_position();
let new_fragment: String = fragment.chars().skip(start_ix.as_usize()).collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
self.cursor.next();
}
new_content.push_tree(self.cursor.suffix());
new_content
}
}
pub trait BufferSumTree {
/// Replace an item at the given offset with a new BufferText item.
/// The edit happens in-palace.
fn replace_item_at_offset(&mut self, offset: CharOffset, item: BufferText);
/// Renders a debug representation of this SumTree.
fn debug(&self) -> String;
/// Returns the url with the given link count if the link exists.
fn url_at_link_count(&self, link_count: &LinkCount) -> Option<String>;
/// Returns the color with the given color count if there is a decorated syntax color.
fn color_at_color_count(&self, color_count: &SyntaxColorId) -> Option<ColorU>;
/// Iterate over items in the given character range.
fn items_in_range<'a, U: sum_tree::Dimension<'a, BufferSummary>>(
&'a self,
range: Range<CharOffset>,
) -> BoundedCursor<'a, U>;
/// Append a new str to the end of the SumTree. If the last item in the tree is a Text
/// fragment and it has extra byte size left, fill that text fragment first before creating
/// a new one.
fn append_str(&mut self, s: &str);
}
impl BufferSumTree for SumTree<BufferText> {
fn replace_item_at_offset(&mut self, offset: CharOffset, item: BufferText) {
let old_tree = self.clone();
let mut new_tree = SumTree::new();
let cursor = old_tree.cursor::<CharOffset, CharOffset>();
let mut buffer_cursor = BufferCursor::new(cursor);
new_tree.push_tree(buffer_cursor.slice_to_offset_after_markers(offset));
new_tree.push(item);
buffer_cursor.next();
new_tree.push_tree(buffer_cursor.suffix());
drop(buffer_cursor);
*self = new_tree;
}
fn items_in_range<'a, U: sum_tree::Dimension<'a, BufferSummary>>(
&'a self,
range: Range<CharOffset>,
) -> BoundedCursor<'a, U> {
let inner = self.cursor::<CharOffset, U>();
let mut buffer_cursor = BufferCursor::new(inner);
buffer_cursor.seek_to_offset_after_markers(range.start);
BoundedCursor {
inner: buffer_cursor,
end: range.end,
}
}
fn url_at_link_count(&self, link_count: &LinkCount) -> Option<String> {
let mut cursor = self.cursor::<LinkCount, ()>();
cursor.seek(link_count, SeekBias::Left);
match cursor.item() {
Some(BufferText::Link(LinkMarker::Start(url))) => Some(url.clone()),
_ => None,
}
}
fn color_at_color_count(&self, color_count: &SyntaxColorId) -> Option<ColorU> {
let mut cursor = self.cursor::<SyntaxColorId, ()>();
cursor.seek(color_count, SeekBias::Left);
match cursor.item() {
Some(BufferText::Color(ColorMarker::Start(color))) => Some(*color),
_ => None,
}
}
fn debug(&self) -> String {
use std::fmt::Write;
let mut cursor = self.cursor::<(), ()>();
cursor.descend_to_first_item(self, |_| true);
let mut total_string = String::new();
while let Some(item) = cursor.item() {
let _ = write!(&mut total_string, "{item}");
cursor.next();
}
total_string
}
fn append_str(&mut self, s: &str) {
let trailing_newline = s.ends_with('\n');
let mut is_first = true;
let mut new_fragments = Vec::new();
// Split str into lines first. For linebreaks, we need to push BufferText::Newline.
let mut lines = s.lines().peekable();
while let Some(line) = lines.next() {
let mut text = line;
// For the first fragment we are pushing, try to fill up the trailing text fragment if 1) it exists 2) it has extra byte space.
if is_first && !self.is_empty() {
self.update_last(|last_text| {
if let BufferText::Text {
fragment,
char_count,
} = last_text
{
let split_ix = if fragment.len() + text.len() <= TEXT_FRAGMENT_SIZE {
text.len()
} else {
let mut split_ix = TEXT_FRAGMENT_SIZE
.saturating_sub(fragment.len())
.min(text.len());
while !text.is_char_boundary(split_ix) {
split_ix -= 1;
}
split_ix
};
let (suffix, remainder) = text.split_at(split_ix);
fragment.push_str(suffix);
*char_count = fragment.chars().count() as u8;
text = remainder;
}
});
}
is_first = false;
// If there are still remaining text, push it as a new fragment.
while !text.is_empty() {
let mut split_ix = text.len().min(TEXT_FRAGMENT_SIZE);
while !text.is_char_boundary(split_ix) {
split_ix -= 1;
}
let (chunk, remainder) = text.split_at(split_ix);
new_fragments.push(BufferText::Text {
char_count: chunk.chars().count() as u8,
fragment: ArrayString::from(chunk).unwrap(),
});
text = remainder;
}
if lines.peek().is_some() || trailing_newline {
new_fragments.push(BufferText::Newline);
}
}
self.extend(new_fragments);
}
}
/// A [`Cursor`] that limits itself to only consuming a maximum number of characters.
/// Use this as a building block for range-based iteration.
pub struct BoundedCursor<'a, U: Dimension<'a, BufferSummary>> {
inner: BufferCursor<'a, U>,
end: CharOffset,
}
impl<'a, U> BoundedCursor<'a, U>
where
U: sum_tree::Dimension<'a, BufferSummary>,
{
/// Summary at the start of the current cursor position (see [`Cursor::start`]).
pub fn start(&self) -> &U {
self.inner.start()
}
}
impl<'a, U> Iterator for BoundedCursor<'a, U>
where
U: sum_tree::Dimension<'a, BufferSummary>,
{
type Item = &'a BufferText;
fn next(&mut self) -> Option<Self::Item> {
if self.inner.offset() >= self.end {
None
} else {
let item = self.inner.item()?;
self.inner.next();
Some(item)
}
}
}
#[cfg(test)]
#[path = "cursor_test.rs"]
mod tests;
+220
View File
@@ -0,0 +1,220 @@
use sum_tree::SumTree;
use crate::content::text::{BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir};
use string_offset::CharOffset;
use super::{BufferCursor, BufferSumTree};
/// Helper function to count the number of Text fragments in a SumTree
fn count_text_fragments(tree: &SumTree<BufferText>) -> usize {
let mut cursor = tree.cursor::<(), ()>();
cursor.descend_to_first_item(tree, |_| true);
let mut count = 0;
while let Some(item) = cursor.item() {
if matches!(item, BufferText::Text { .. }) {
count += 1;
}
cursor.next();
}
count
}
#[test]
fn test_plain_text_before_markers() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("This is some text");
tree.push(BufferText::Newline);
tree.append_str("New line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text");
assert_eq!(
tree.debug(),
"<text>This is some text\\nNew line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text"
);
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
text_cursor.seek_to_offset_before_markers(CharOffset::from(3));
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(6));
assert_eq!(new_content.debug(), "is ");
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(20));
assert_eq!(new_content.debug(), "is some text\\nN");
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(40));
assert_eq!(new_content.debug(), "ew line veryyyyyyyyy");
}
#[test]
fn test_plain_text_after_markers() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("This is some text");
tree.push(BufferText::Newline);
tree.append_str("New line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text");
assert_eq!(
tree.debug(),
"<text>This is some text\\nNew line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text"
);
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
text_cursor.seek_to_offset_after_markers(CharOffset::from(3));
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(6));
assert_eq!(new_content.debug(), "is ");
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(20));
assert_eq!(new_content.debug(), "is some text\\nN");
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(40));
assert_eq!(new_content.debug(), "ew line veryyyyyyyyy");
}
#[test]
fn test_append_str() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.append_str("Som");
tree.append_str("ething");
tree.append_str(" long stringggggggggggggg");
assert_eq!(tree.debug(), "Something long stringggggggggggggg");
}
#[test]
fn test_append_str_merges_with_existing_fragment() {
// Test the bug fix: is_first should be true when appending to allow merging
// with the last text fragment if it has space remaining
let mut tree: SumTree<BufferText> = SumTree::new();
// Add initial content that creates a text fragment with remaining capacity
tree.append_str("Initial");
// Count fragments before second append
let text_fragments_before = count_text_fragments(&tree);
// Append more text - this should merge with the existing fragment if possible
tree.append_str(" text");
// Count fragments after second append
let text_fragments_after = count_text_fragments(&tree);
// The result should be a single merged fragment, not separate ones
assert_eq!(tree.debug(), "Initial text");
assert_eq!(
text_fragments_before, 1,
"Should have 1 fragment before second append"
);
assert_eq!(
text_fragments_after, 1,
"Should still have 1 fragment after merging"
);
// Verify the internal structure by checking we can iterate correctly
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut buffer_cursor = BufferCursor::new(cursor);
assert_eq!(buffer_cursor.char_at(CharOffset::from(0)), Some('I'));
assert_eq!(buffer_cursor.char_at(CharOffset::from(7)), Some(' '));
assert_eq!(buffer_cursor.char_at(CharOffset::from(8)), Some('t'));
assert_eq!(buffer_cursor.char_at(CharOffset::from(11)), Some('t'));
}
#[test]
fn test_append_str_creates_new_fragment_when_full() {
use crate::content::text::TEXT_FRAGMENT_SIZE;
let mut tree: SumTree<BufferText> = SumTree::new();
// Create a text fragment that's at the TEXT_FRAGMENT_SIZE limit
let large_text = "a".repeat(TEXT_FRAGMENT_SIZE);
tree.append_str(&large_text);
let fragments_before = count_text_fragments(&tree);
// Append additional text - this should create a new fragment since the first is full
tree.append_str("extra");
let fragments_after = count_text_fragments(&tree);
// Should create a new fragment since the first one is at capacity
let expected = format!("{large_text}extra");
assert_eq!(tree.debug(), expected);
assert_eq!(fragments_before, 1, "Should have 1 fragment before append");
assert_eq!(
fragments_after, 2,
"Should have 2 fragments after append when first is full"
);
}
#[test]
fn test_styled_text_before_markers() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("Plain text");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::Start,
});
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.append_str("BI");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
tree.append_str("Just Italic");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::End,
});
tree.append_str("Plain text");
assert_eq!(
tree.debug(),
"<text>Plain text<b_s><i_s>BI<b_e>Just Italic<i_e>Plain text"
);
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
text_cursor.seek_to_offset_before_markers(CharOffset::from(11));
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(13));
assert_eq!(new_content.debug(), "<b_s><i_s>BI<b_e>");
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(17));
assert_eq!(new_content.debug(), "Just");
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(24));
assert_eq!(new_content.debug(), " Italic");
let new_content = text_cursor.suffix();
assert_eq!(new_content.debug(), "<i_e>Plain text");
}
#[test]
fn test_char_at() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.append_str("Line");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::Start,
});
tree.append_str("String");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
tree.push(BufferText::Newline);
tree.append_str("Next");
assert_eq!(tree.debug(), "Line<b_s>String<b_e>\\nNext");
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
assert_eq!(text_cursor.char_at(CharOffset::from(1)), Some('i'));
assert_eq!(text_cursor.char_at(CharOffset::from(3)), Some('e'));
assert_eq!(text_cursor.char_at(CharOffset::from(4)), Some('S'));
assert_eq!(text_cursor.char_at(CharOffset::from(10)), Some('\n'));
}
+120
View File
@@ -0,0 +1,120 @@
//! Text diff computation for incremental buffer updates.
//!
//! 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 string_offset::{ByteOffset, CharOffset};
use super::buffer::{Buffer, ToBufferCharOffset};
/// A computed diff between two strings.
///
/// The edits are represented as byte ranges in the old text and their replacement strings.
/// These can be applied to transform the old text into the new text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextDiff {
/// List of edits: (old_byte_range, new_text)
pub edits: Vec<(Range<usize>, String)>,
}
impl TextDiff {
/// Returns true if this diff represents no changes.
pub fn is_empty(&self) -> bool {
self.edits.is_empty()
}
/// Convert byte-range edits to CharOffset-range edits.
///
/// The buffer uses 1-indexed coordinates (first editable character is at CharOffset(1)).
/// The diff byte offsets are 0-indexed relative to the plain text, so we add 1 to
/// convert to the buffer's 1-indexed system before using to_buffer_char_offset.
pub fn to_char_offset_edits(&self, buffer: &Buffer) -> Vec<(Range<CharOffset>, String)> {
self.edits
.iter()
.map(|(byte_range, new_text)| {
// Add 1 to convert from 0-indexed plain text byte offset to 1-indexed buffer byte offset
let start = ByteOffset::from(byte_range.start + 1).to_buffer_char_offset(buffer);
let end = ByteOffset::from(byte_range.end + 1).to_buffer_char_offset(buffer);
(start..end, new_text.clone())
})
.collect()
}
}
/// Compute a diff between two strings.
///
/// This uses a line-based diff algorithm (Histogram) for efficiency.
/// Returns a list of edits as (byte_range_in_old, replacement_text) pairs.
pub async fn text_diff(old_text: &str, new_text: &str) -> TextDiff {
let input = InternedInput::new(old_text, new_text);
// Yield here to prevent doing more work if the task is aborted.
futures_lite::future::yield_now().await;
// Only compute line-based diff for now. Zed does more fine-grained word-level diffing for smaller hunks
// but I don't think it's worth it for our use case.
let edits = diff_internal(&input, new_text).await;
TextDiff { edits }
}
async fn diff_internal(input: &InternedInput<&str>, new_text: &str) -> Vec<(Range<usize>, String)> {
let mut old_offset = 0;
let mut new_offset = 0;
let mut old_token_ix = 0;
let mut new_token_ix = 0;
let mut edits = Vec::new();
let diff = Diff::compute(Algorithm::Histogram, input);
// Yield here to prevent doing more work if the task is aborted.
futures_lite::future::yield_now().await;
for hunk in diff.hunks() {
// Calculate byte offsets for unchanged tokens before this hunk
old_offset += token_len(
input,
&input.before[old_token_ix as usize..hunk.before.start as usize],
);
new_offset += token_len(
input,
&input.after[new_token_ix as usize..hunk.after.start as usize],
);
// Calculate byte lengths of the changed tokens
let old_len = token_len(
input,
&input.before[hunk.before.start as usize..hunk.before.end as usize],
);
let new_len = token_len(
input,
&input.after[hunk.after.start as usize..hunk.after.end as usize],
);
let old_byte_range = old_offset..old_offset + old_len;
let new_byte_range = new_offset..new_offset + new_len;
old_token_ix = hunk.before.end;
new_token_ix = hunk.after.end;
old_offset = old_byte_range.end;
new_offset = new_byte_range.end;
let replacement = if new_byte_range.is_empty() {
String::new()
} else {
new_text[new_byte_range].to_string()
};
edits.push((old_byte_range, replacement));
}
edits
}
/// Calculate total byte length of a sequence of tokens.
fn token_len(input: &InternedInput<&str>, tokens: &[Token]) -> usize {
tokens
.iter()
.map(|token| input.interner[*token].len())
.sum()
}
File diff suppressed because it is too large Load Diff
+763
View File
@@ -0,0 +1,763 @@
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 std::path::Path;
use string_offset::CharOffset;
use warp_core::features::FeatureFlag;
use warpui::{
App, SingletonEntity,
assets::asset_cache::{AssetCache, AssetSource, AssetState},
fonts::{Properties, Style, Weight},
image_cache::ImageType,
text_layout::{LayoutCache, StyleAndFont, TextStyle},
};
#[test]
fn test_highlight_urls() {
let mut test_styled_buffer_runs = vec![
StyledBufferRun {
run: "https:".to_string(),
text_styles: TextStylesWithMetadata::default().bold(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "//".to_string(),
text_styles: TextStylesWithMetadata::default().italic(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "google.com".to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert_eq!(
highlight_urls(&test_styled_buffer_runs),
[ParsedUrl {
url_range: 0..18,
link: "https://google.com".to_string()
},]
);
test_styled_buffer_runs.extend(vec![
StyledBufferRun {
run: " abc ".to_string(),
text_styles: TextStylesWithMetadata::default().bold(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "https://warp.dev".to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::PlainText,
},
]);
assert_eq!(
highlight_urls(&test_styled_buffer_runs),
[
ParsedUrl {
url_range: 0..18,
link: "https://google.com".to_string()
},
ParsedUrl {
url_range: 23..39,
link: "https://warp.dev".to_string()
}
]
);
}
#[test]
fn test_highlight_urls_unicode() {
let test_runs = vec![StyledBufferRun {
run: "This (not https://example.com) is a 🔥 link about a 🇨🇦 🏡:\u{a0}https://warp.dev"
.to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
}];
assert_eq!(
highlight_urls(&test_runs),
[
ParsedUrl {
url_range: 10..29,
link: "https://example.com".to_string()
},
ParsedUrl {
url_range: 57..73,
link: "https://warp.dev".to_string()
}
]
)
}
#[test]
fn test_highlight_incomplete_url() {
// Tests that we can highlight the valid range of a URL that's still being typed.
// URLs can't end in a `.`, so the detector stops at `www`.
let test_runs = vec![StyledBufferRun {
run: "Word https://www. later".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
}];
assert_eq!(
highlight_urls(&test_runs),
[ParsedUrl {
url_range: 5..16,
link: "https://www".to_string()
},]
)
}
#[test]
fn test_links_not_auto_highlighted() {
// Test that links whose tags look like URLs aren't auto-linked, but also that they don't
// prevent auto-linking other URLs.
let runs = &[
StyledBufferRun {
run: "first link is https://warp.dev ".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "http://example.com".to_string(),
text_styles: TextStylesWithMetadata::default().link("https://warp.dev".to_string()),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: " second is https://google.com".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert_eq!(
highlight_urls(runs),
&[
ParsedUrl {
url_range: 14..30,
link: "https://warp.dev".to_string()
},
ParsedUrl {
url_range: 60..78,
link: "https://google.com".to_string()
}
]
)
}
#[test]
fn test_highlight_url_before_link() {
// Test that a URL right before an actual hyperlink is still highlighted.
let runs = &[
StyledBufferRun {
run: "https://example.com".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "hyperlink".to_string(),
text_styles: TextStylesWithMetadata::default().link("https://example.com".to_string()),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "https://warp.dev".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert_eq!(
highlight_urls(runs),
vec![
ParsedUrl {
url_range: 0..19,
link: "https://example.com".to_string()
},
ParsedUrl {
url_range: 28..44,
link: "https://warp.dev".to_string()
}
]
)
}
#[test]
fn test_text_around_link_not_auto_highlighted() {
// Test that text which, without the link in the middle, would be a URL is not auto-linked.
let runs = &[
StyledBufferRun {
run: "ht".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "alink".to_string(),
text_styles: TextStylesWithMetadata::default().link("https://warp.dev".to_string()),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "tps://example.com".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert!(highlight_urls(runs).is_empty());
}
#[test]
fn test_layout_partial_url() {
// Regression test for laying out a partially-styled autodetected URL (CLD-871).
App::test((), |app| async move {
let layout_cache = LayoutCache::new();
let runs = vec![
StyledBufferRun {
run: "A link: https://www.".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "example.com".to_string(),
text_styles: TextStylesWithMetadata::default().bold(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "/path text".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
app.read(|ctx| {
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let mut line = LayOutArgs::new();
line.highlighted_urls = highlight_urls(&runs);
line.next_url_index = 0;
for run in runs.iter() {
line.layout_run(
&text_layout,
run,
&text_layout.paragraph_styles(&BufferBlockStyle::PlainText),
);
}
let family_id = TEST_STYLES.base_text.font_family;
let base_styles =
StyleAndFont::new(family_id, Properties::default(), TextStyle::default());
assert_eq!(&line.text, "A link: https://www.example.com/path text");
assert_eq!(
&line.style_runs,
&[
(0..8, base_styles),
(8..20, add_link_to_style_and_font(base_styles)),
(
20..31,
add_link_to_style_and_font(StyleAndFont::new(
family_id,
Properties::default().weight(Weight::Bold),
TextStyle::default()
))
),
(31..36, add_link_to_style_and_font(base_styles)),
(36..41, base_styles)
]
)
});
})
}
#[test]
fn test_layout_mermaid_block_uses_loaded_svg_aspect_ratio() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let content = "graph TD\nA[Start] --> B[Finish]\n";
let asset_source = mermaid_asset_source(content);
let mermaid_load = 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),
AssetState::Loaded { .. } => None,
AssetState::Evicted => panic!("Mermaid asset should not be evicted during test"),
AssetState::FailedToLoad(err) => {
panic!("Mermaid asset should load successfully: {err}")
}
}
});
if let Some(future) = mermaid_load {
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_style = BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
};
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: block_style.clone(),
}],
style: block_style.clone(),
content_length: CharOffset::from(content.chars().count()),
};
let spacing = TEST_STYLES.block_spacings.from_block_style(&block_style);
let mermaid_diagram = mermaid_diagram_layout(content, &text_layout, spacing, ctx);
let (item, _has_trailing_newline) = layout_mermaid_diagram_block(
block,
mermaid_diagram.0,
mermaid_diagram.1,
BlockLocation::Middle,
false,
)
.expect("Mermaid layout should succeed");
let asset_cache = AssetCache::as_ref(ctx);
let svg = match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loaded { data } => match data.as_ref() {
ImageType::Svg { svg } => svg.clone(),
_ => panic!("expected loaded svg asset"),
},
AssetState::Loading { .. } => panic!("Mermaid asset should already be loaded"),
AssetState::Evicted => panic!("Mermaid asset should not be evicted during test"),
AssetState::FailedToLoad(err) => {
panic!("Mermaid asset should load successfully: {err}")
}
};
match &item {
BlockItem::MermaidDiagram {
content_length,
config,
..
} => {
let intrinsic_size = svg.size();
let expected_width = (800.
- TEST_STYLES
.block_spacings
.from_block_style(&block_style)
.x_axis_offset()
.as_f32())
.min(intrinsic_size.width());
let expected_height =
expected_width * intrinsic_size.height() / intrinsic_size.width();
assert_eq!(*content_length, CharOffset::from(content.chars().count()));
assert!((config.width.as_f32() - expected_width).abs() < 0.5);
assert!((config.height.as_f32() - expected_height).abs() < 0.5);
assert!((item.content_height().as_f32() - config.height.as_f32()).abs() < 0.5);
assert_eq!(item.lines(), 1.into());
assert_eq!(item.first_line_height(), config.height.as_f32());
}
item => panic!("expected MermaidDiagram block, got {item:?}"),
}
});
})
}
#[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 } => {
assert_eq!(Path::new(&path), Path::new("/tmp/session/diagram.png"));
}
source => panic!("expected local file asset source, got {source:?}"),
}
}
#[test]
fn test_layout_text_block_uses_rich_table_when_flag_enabled() {
App::test((), |app| async move {
app.read(|ctx| {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let content = "short\tmuch longer\ncell\trow\n";
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let (item, has_trailing_newline) =
layout_text_block(block, &text_layout, BlockLocation::Middle, false)
.expect("table layout should succeed");
assert!(matches!(item, BlockItem::Table(_)));
assert!(!has_trailing_newline);
});
})
}
#[test]
fn test_layout_text_block_uses_plain_text_when_flag_disabled() {
App::test((), |app| async move {
app.read(|ctx| {
let _flag = FeatureFlag::MarkdownTables.override_enabled(false);
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let content = "short\tmuch longer\ncell\trow\n";
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let (item, _has_trailing_newline) =
layout_text_block(block, &text_layout, BlockLocation::Middle, false)
.expect("table layout should succeed");
assert!(matches!(item, BlockItem::Paragraph(_)));
});
})
}
#[test]
fn test_layout_table_block_caches_cell_text_frames() {
App::test((), |app| async move {
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let content = "short\tmuch longer\ncell\trow\n";
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let table = match layout_table_block(
block,
&text_layout,
TEST_STYLES
.block_spacings
.from_block_style(&BufferBlockStyle::table(Vec::new())),
)
.expect("table layout should succeed")
{
BlockItem::Table(table) => table,
item => panic!("expected table block, got {item:?}"),
};
assert_eq!(table.cell_text_frames.len(), 2);
assert_eq!(table.cell_text_frames[0].len(), 2);
assert_eq!(table.cell_text_frames[1].len(), 2);
assert_eq!(table.cell_layouts.len(), 2);
assert_eq!(table.cell_layouts[0].len(), 2);
assert_eq!(table.cell_layouts[1].len(), 2);
assert!(
table.cell_text_frames[0][1].max_width()
<= table.column_widths[1].as_f32() - table.config.style.cell_padding * 2.0
);
});
})
}
#[test]
fn test_layout_table_block_clamps_cell_width_to_max() {
App::test((), |app| async move {
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
// One long cell in the second column that would otherwise blow out the column
// width. The paragraph has no natural break points within the first 500px, so the
// cell must rely on the per-cell max width cap to keep the column size bounded.
let long_content = "word ".repeat(400);
let content = format!("short\t{long_content}\ncell\trow\n");
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.clone(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let table = match layout_table_block(
block,
&text_layout,
TEST_STYLES
.block_spacings
.from_block_style(&BufferBlockStyle::table(Vec::new())),
)
.expect("table layout should succeed")
{
BlockItem::Table(table) => table,
item => panic!("expected table block, got {item:?}"),
};
let cell_padding = table.config.style.cell_padding;
let expected_max_cell_width = cell_padding * 2.0 + 500.0;
assert!(
table.column_widths[1].as_f32() <= expected_max_cell_width + f32::EPSILON,
"long cell column width {} should be clamped to {}",
table.column_widths[1].as_f32(),
expected_max_cell_width,
);
// The clamped cell frame must be laid out within the clamped column's content
// width so soft-wrap can occur inside the cell at paint time.
let max_content_width =
table.column_widths[1].as_f32() - table.config.style.cell_padding * 2.0;
assert!(
table.cell_text_frames[0][1].max_width() <= max_content_width + f32::EPSILON,
"long cell frame max width {} should fit within clamped content width {}",
table.cell_text_frames[0][1].max_width(),
max_content_width,
);
});
})
}
#[test]
fn test_table_inline_style_runs_apply_header_bold_default() {
App::test((), |app| async move {
let layout_cache = LayoutCache::new();
app.read(|ctx| {
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let mut header_style =
text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new()));
header_style.font_weight = Weight::Bold;
let table = crate::content::text::table_from_internal_format_with_inline_markdown(
"Header\tValue\nText\tCell\n",
Vec::new(),
);
let layout_input = markdown_inline_to_text_and_style_runs(
&table.headers[0],
&header_style,
Some(header_style.text_color),
Some(TEST_STYLES.table_style.header_background),
);
assert_eq!(layout_input.text, "Header");
assert!(!layout_input.style_runs.is_empty());
assert!(
layout_input
.style_runs
.iter()
.all(|(_, style)| style.properties.weight == Weight::Bold)
);
});
});
}
#[test]
fn test_table_inline_style_runs_preserve_markdown_cell_styles() {
App::test((), |app| async move {
let layout_cache = LayoutCache::new();
app.read(|ctx| {
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let body_style = text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new()));
let table = crate::content::text::table_from_internal_format_with_inline_markdown(
"Header\tValue\nText\t**Bold** *Italic* [Link](https://warp.dev) `code`\n",
Vec::new(),
);
let layout_input = markdown_inline_to_text_and_style_runs(
&table.rows[0][1],
&body_style,
Some(body_style.text_color),
Some(TEST_STYLES.table_style.cell_background),
);
assert_eq!(layout_input.text, "Bold Italic Link code");
assert_eq!(layout_input.style_runs.len(), 7);
assert_eq!(layout_input.style_runs[0].0, 0..4);
assert_eq!(layout_input.style_runs[0].1.properties.weight, Weight::Bold);
assert_eq!(layout_input.style_runs[2].0, 5..11);
assert_eq!(layout_input.style_runs[2].1.properties.style, Style::Italic);
assert_eq!(layout_input.style_runs[4].0, 12..16);
assert!(
layout_input.style_runs[4]
.1
.style
.foreground_color
.is_some()
);
assert!(layout_input.style_runs[4].1.style.underline_color.is_some());
assert_eq!(layout_input.style_runs[6].0, 17..21);
assert!(
layout_input.style_runs[6]
.1
.style
.background_color
.is_some()
);
});
});
}
#[test]
fn test_layout_code_block_urls() {
// Regression test for laying out URLs in a code block, which contains multiple lines.
App::test((), |app| async move {
let runs = vec![
StyledBufferRun {
run: "curl -o myfile.txt http://example.com/myfile.txt\n".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
},
StyledBufferRun {
run: "vim myfile.txt\n".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
},
StyledBufferRun {
run: "rsync myfile.txt ssh://user@server.com\n".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
},
];
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let paragraph_styles = text_layout.paragraph_styles(&BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
});
let family_id = TEST_STYLES.code_text.font_family;
let base_styles =
StyleAndFont::new(family_id, Properties::default(), TextStyle::default());
let mut line = LayOutArgs::new();
line.highlighted_urls = highlight_urls(&runs);
line.next_url_index = 0;
// First, make sure that we detected the URLs correctly.
assert_eq!(
&line.highlighted_urls,
&[
ParsedUrl {
url_range: 19..48,
link: "http://example.com/myfile.txt".to_string()
},
ParsedUrl {
// URL offsets count painted characters, not newlines.
url_range: 79..100,
link: "ssh://user@server.com".to_string()
}
]
);
// Lay out each line of code 1 by 1 to verify the intermediate state.
assert!(line.layout_run(&text_layout, &runs[0], &paragraph_styles));
assert_eq!(
&line.text,
"curl -o myfile.txt http://example.com/myfile.txt"
);
assert_eq!(
&line.style_runs,
&[
(0..19, base_styles),
(19..48, add_link_to_style_and_font(base_styles)),
]
);
line.reset_for_newline();
assert!(line.layout_run(&text_layout, &runs[1], &paragraph_styles));
assert_eq!(&line.text, "vim myfile.txt");
assert_eq!(&line.style_runs, &[(0..14, base_styles)]);
line.reset_for_newline();
assert!(line.layout_run(&text_layout, &runs[2], &paragraph_styles));
assert_eq!(&line.text, "rsync myfile.txt ssh://user@server.com");
assert_eq!(
&line.style_runs,
&[
(0..17, base_styles),
(17..38, add_link_to_style_and_font(base_styles)),
]
);
});
})
}
+452
View File
@@ -0,0 +1,452 @@
//! Regex-based search through a [`Buffer`].
//!
//! As in other cases, this uses the `regex_automata` [`DFA`] API directly, so that it does not
//! need to copy the buffer into a string (the regex APIs all require an `&[u8]` or equivalent).
//! This has a few downsides, however:
//! * Lazy DFAs do not handle Unicode word boundaries well (see
//! [`regex_automata::hybrid::dfa::Config::unicode_word_boundary`])
//! * We miss out on optimizations that the [`regex_automata::meta::Regex`] matcher makes by
//! choosing between different engines (like avoiding a regex engine entirely for simple
//! literals). In practice, this is likely not a major loss because the literal search
//! 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 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 sum_tree::SumTree;
use string_offset::CharOffset;
use crate::search::RestorableSearchResults;
use super::{
buffer::Buffer,
cursor::BufferCursor,
text::{BufferSummary, BufferText},
};
#[cfg(test)]
#[path = "find_tests.rs"]
mod tests;
/// A match for a text search.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Match {
/// The starting offset of the match (inclusive).
pub start: CharOffset,
/// The ending offset of the match (exclusive).
pub end: CharOffset,
}
/// A compiled, reusable search query.
#[derive(Debug)]
pub struct Query {
/// Box the inner [`Engine`] because it may be large and [`Query`] is moved frequently.
engine: Box<Engine>,
}
/// Results of a search.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResults {
pub matches: Vec<Match>,
}
impl RestorableSearchResults for &SearchResults {
fn valid_matches(&self) -> impl Iterator<Item = (usize, CharOffset)> {
self.matches
.iter()
.enumerate()
.map(|(index, m)| (index, m.start))
}
}
/// Configuration for a search query.
#[derive(Debug, Clone, Copy)]
pub struct SearchConfig<'a> {
query: &'a str,
case_sensitive: bool,
regex: bool,
skip_hidden: bool,
hidden_ranges: Option<&'a RangeSet<CharOffset>>,
}
impl<'a> SearchConfig<'a> {
/// Build configuration to search for the literal `query`. By default, the search is
/// case-sensitive and includes hidden content.
pub fn new(query: &'a str) -> Self {
Self {
query,
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: None,
}
}
/// Build configuration to search for `pattern` as a regular expression. By default, the search
/// is case-sensitive and includes hidden content.
pub fn regex(pattern: &'a str) -> Self {
Self {
query: pattern,
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
}
}
/// Set whether or not this search is case-sensitive.
pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
/// Set whether or not the search query is interpreted as a regular expression.
pub fn with_regex(mut self, regex: bool) -> Self {
self.regex = regex;
self
}
/// Set whether or not to skip hidden content during search. When enabled, text within
/// hidden regions (marked by `BufferTextStyle::Hidden`) will not be searched and will
/// act as match boundaries.
pub fn with_skip_hidden(mut self, skip_hidden: bool) -> Self {
self.skip_hidden = skip_hidden;
self
}
pub fn with_hidden_ranges(mut self, hidden_lines: &'a RangeSet<CharOffset>) -> Self {
self.hidden_ranges = Some(hidden_lines);
self
}
}
#[derive(Debug)]
struct Engine {
// For now, this uses the same DFA approach as the block list. We should
// consider using the meta::Regex implementation instead for better Unicode support (and
// possibly better performance for literal searches).
// Because all matches are constructed up front, we only need forward searching.
forward_dfa: DFA,
forward_cache: Cache,
reverse_dfa: DFA,
reverse_cache: Cache,
skip_hidden: bool,
hidden_ranges: Option<RangeSet<CharOffset>>,
}
impl Engine {
fn new(config: &SearchConfig) -> Result<Self, Box<BuildError>> {
log::trace!("Compiling {config:?}");
let mut builder = DFA::builder();
builder
.syntax(
Config::new()
.case_insensitive(!config.case_sensitive)
// Enable multi-line mode by default - because the buffer always starts with a
// block marker, `^` anchors are otherwise useless.
.multi_line(true),
)
.configure(DFA::config().unicode_word_boundary(true));
let query = if config.regex {
Cow::Borrowed(config.query)
} else {
Cow::Owned(regex_syntax::escape(config.query))
};
let forward_dfa = builder.clone().build(query.as_ref())?;
// See https://github.com/rust-lang/regex/blob/837fd85e79fac2a4ea64030411b9a4a7b17dfa42/regex-automata/src/hybrid/regex.rs#L793-L802
// and https://github.com/rust-lang/regex/blob/837fd85e79fac2a4ea64030411b9a4a7b17dfa42/regex-automata/src/hybrid/regex.rs#L87-L94
//
// This configuration ensures we find the right match.
let reverse_dfa = builder
.configure(
DFA::config()
.specialize_start_states(false)
.match_kind(MatchKind::All),
)
.thompson(thompson::Config::new().reverse(true))
.build(query.as_ref())?;
let forward_cache = forward_dfa.create_cache();
let reverse_cache = reverse_dfa.create_cache();
Ok(Self {
forward_dfa,
forward_cache,
reverse_dfa,
reverse_cache,
skip_hidden: config.skip_hidden,
hidden_ranges: config.hidden_ranges.cloned(),
})
}
/// Run a search, blocking until it finishes completely. See [`Engine::find`].
#[cfg(test)]
fn find_blocking(
&mut self,
buffer: &SumTree<BufferText>,
buffer_offset: CharOffset,
) -> anyhow::Result<Vec<Match>> {
warpui::r#async::block_on(self.find(buffer, buffer_offset))
}
/// Find all matches for this pattern in the given slice of content.
///
/// All returned offsets are shifted by `buffer_offset` - if the `SumTree` doesn't
/// start at the beginning of the buffer, use this to adjust accordingly (for example, to
/// search in a range of text).
///
/// The search will yield to the scheduler periodically so that it may be cancelled.
async fn find(
&mut self,
buffer: &SumTree<BufferText>,
buffer_offset: CharOffset,
) -> anyhow::Result<Vec<Match>> {
let mut results = vec![];
let mut cursor = buffer.cursor::<CharOffset, BufferSummary>();
cursor.descend_to_first_item(buffer, |_| true);
let mut buffer_cursor = BufferCursor::new(cursor);
while let Some(match_end) = self.next_match(&mut buffer_cursor, SearchDirection::Forward)? {
log::trace!("Found match ending at {match_end}");
// The forward DFA reports the _end_ of the match, so we then use the reverse DFA to
// find its start.
let cursor = buffer.cursor::<CharOffset, BufferSummary>();
buffer_cursor = BufferCursor::new(cursor);
// Seek to the match end location - this might not be the current cursor position,
// because next_match will keep advancing to look for longer matches if possible.
buffer_cursor.seek_to_offset_before_markers(match_end);
// Since match_end is exclusive, move to the prev char position to make sure we are reverse
// iterating from the correct location.
buffer_cursor.prev_char_position();
// Clone the cursor to not lose the search position - the next iteration will resume
// searching after this match.
let mut reverse_cursor = buffer_cursor.clone();
if let Some(match_start) =
self.next_match(&mut reverse_cursor, SearchDirection::Reverse)?
{
// The DFA-reported match end will be the first character _after_ the match - the
// DFA state is always delayed by 1 byte to support look-around operators. For the
// match end, this is what we want, since it's an exclusive end.
// For the start, we add 1 to get the inclusive start offset (adding because the
// start is found with a DFA moving backwards).
results.push(Match {
start: match_start + buffer_offset + 1,
end: match_end + buffer_offset,
});
log::trace!("Match started at {}", match_start + 1);
} else {
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
// match to prevent an infinite loop.
buffer_cursor.next_char_position();
// Individual calls to `next_match` should be fairly fast (barring a pathologically
// slow regular expression), but searching a buffer with many matches could still be
// slow. As a rough heuristic, we yield every 1000 matches so that the future can be
// meaningfully cancelled. If this is insufficient, we could also yield every X
// characters.
if results.len() % 1000 == 1 {
futures_lite::future::yield_now().await;
}
}
Ok(results)
}
/// Find the next pattern match in the given direction, starting at the current `cursor` location.
/// Where variable-length matches are allowed, this will find the longest one (for example, if
/// searching for `a+` in `aaab`, this will return the offset up to `aaa`, even though `a` by
/// itself is also a match).
fn next_match(
&mut self,
cursor: &mut BufferCursor<BufferSummary>,
direction: SearchDirection,
) -> anyhow::Result<Option<CharOffset>> {
let skip_hidden = self.skip_hidden;
let (dfa, cache) = direction.dfa_and_cache(
&self.forward_dfa,
&mut self.forward_cache,
&self.reverse_dfa,
&mut self.reverse_cache,
);
let mut state = direction.start_state(dfa, cache)?;
// We want to find the _longest_ match in a given direction. This means we have to keep
// searching past a match state, until we reach a dead state or end of input.
let mut match_location = None;
'items: while let Some(item) = cursor.item() {
let start_char = cursor.start().text.chars;
log::trace!("Advancing state machine by {item:?} @ {start_char}");
match item {
BufferText::Text { .. } => {
if skip_hidden
&& self
.hidden_ranges
.as_ref()
.map(|hl| hl.contains(&start_char))
.unwrap_or(false)
{
// Hidden text interrupts search. If we've already found a match, return it.
// Otherwise, see if advancing to the EOI state triggers a match (e.g. if the
// last character before the hidden text was a match).
if match_location.is_some() {
break 'items;
} else {
state = dfa.next_eoi_state(cache, state)?;
if state.is_match() {
match_location = Some(cursor.start().text.chars);
break 'items;
}
state = direction.start_state(dfa, cache)?;
}
} else if let Some(character) = cursor.char() {
let mut bytes = [0u8; 4];
for byte in character.encode_utf8(&mut bytes).bytes() {
state = dfa
.next_state(cache, state, byte)
.context("Couldn't advance to next state")?;
if state.is_quit() {
bail!("DFA entered quit state");
} else if state.is_dead() {
break 'items;
} else if state.is_match() {
match_location = Some(cursor.offset());
}
}
};
}
BufferText::Newline | BufferText::BlockMarker { .. } => {
state = dfa.next_state(cache, state, b'\n')?;
if state.is_quit() {
bail!("DFA entered quit state");
} else if state.is_dead() {
break 'items;
} else if state.is_match() {
match_location = Some(cursor.start().text.chars);
}
}
BufferText::Marker { .. } | BufferText::Link(_) | BufferText::Color(_) => {
// Inline styling is ignored by search.
}
BufferText::Placeholder { .. } | BufferText::BlockItem { .. } => {
// Non-text / non-interactive items interrupt search. If we've already found a
// match, return it. Otherwise, see if advancing to the EOI state triggers a
// match (e.g. if the last character before the item was a match).
if match_location.is_some() {
break 'items;
} else {
state = dfa.next_eoi_state(cache, state)?;
if state.is_match() {
match_location = Some(cursor.start().text.chars);
// We don't need to keep searching in this case, because that would
// allow matching across the boundary.
break 'items;
}
state = direction.start_state(dfa, cache)?;
}
}
}
direction.advance(cursor);
}
state = dfa.next_eoi_state(cache, state)?;
if state.is_match() {
match_location = Some(cursor.offset())
}
Ok(match_location)
}
}
#[derive(Debug, Clone, Copy)]
enum SearchDirection {
Forward,
Reverse,
}
impl SearchDirection {
/// The starting state for searches in this direction.
fn start_state(self, dfa: &DFA, cache: &mut Cache) -> Result<LazyStateID, MatchError> {
match self {
Self::Forward => dfa.start_state_forward(cache, &Input::new("").anchored(Anchored::No)),
// See https://github.com/rust-lang/regex/blob/837fd85e79fac2a4ea64030411b9a4a7b17dfa42/regex-automata/src/hybrid/regex.rs#L483-L488
// For a reverse search, we need to anchor and disable 'earliest'. This makes sure we
// match as much as possible (find the leftmost match) and don't find any matches
// besides the result of the forward search.
Self::Reverse => dfa.start_state_reverse(
cache,
&Input::new("").anchored(Anchored::Yes).earliest(false),
),
}
}
/// Advance the cursor in this direction.
fn advance(self, cursor: &mut BufferCursor<BufferSummary>) {
match self {
Self::Forward => cursor.next_char_position(),
Self::Reverse => {
cursor.prev_char_position();
}
}
}
fn dfa_and_cache<'a>(
self,
forward_dfa: &'a DFA,
forward_cache: &'a mut Cache,
reverse_dfa: &'a DFA,
reverse_cache: &'a mut Cache,
) -> (&'a DFA, &'a mut Cache) {
match self {
Self::Forward => (forward_dfa, forward_cache),
Self::Reverse => (reverse_dfa, reverse_cache),
}
}
}
impl Buffer {
/// Compile a search into a reusable [`Query`].
pub fn prepare_search(&self, config: &SearchConfig) -> anyhow::Result<Query> {
let engine = Box::new(Engine::new(config)?);
Ok(Query { engine })
}
/// Asynchronously run a search from a precompiled query. The search will yield periodically so
/// that it may be cancelled.
///
/// See [`Buffer::prepare_search`].
pub fn search(
&self,
mut query: Query,
) -> impl Future<Output = (Query, anyhow::Result<SearchResults>)> + use<> {
// Cloning the SumTree is cheap, as it wraps an `Arc` of the root node.
let content = self.content.clone();
async move {
let results = query
.engine
.find(&content, CharOffset::zero())
.await
.map(|matches| SearchResults { matches });
(query, results)
}
}
}
+456
View File
@@ -0,0 +1,456 @@
use std::{iter, pin::pin, sync::Once};
use futures_lite::future;
use itertools::Itertools;
use rangemap::RangeSet;
use sum_tree::SumTree;
use warpui::App;
use crate::content::{
buffer::Buffer,
cursor::BufferSumTree,
text::{BufferBlockStyle, BufferText, IndentBehavior},
};
use string_offset::CharOffset;
use super::{Engine, Match, SearchConfig};
#[test]
fn test_search_inline_styles() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"The **first** word, last `word`",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: "t w",
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: None,
},
[(9, 12, "t w"), (20, 23, "t w")],
);
});
});
}
#[test]
fn test_search_across_link() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"visit [our website](https://warp.dev) for more",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: r"visit[\w\s]+site",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 18, "visit our website")],
);
});
});
}
#[test]
fn test_search_longest_match() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"git pull && git log",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: r"git\s+[\w\-]+",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 9, "git pull"), (13, 20, "git log")],
);
});
});
}
#[test]
fn test_end_of_buffer() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"abc",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: r"[a-z]c",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(2, 4, "bc")],
);
assert_matches(
buffer,
&SearchConfig {
query: r"c$",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(3, 4, "c")],
);
assert_matches(
buffer,
&SearchConfig {
query: r"c\b",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(3, 4, "c")],
);
});
});
}
#[test]
fn test_word_boundaries() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"a cat\nlala\npizza\n***\n* A\n* B",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(
buffer.debug(),
"<text>a cat\\nlala\\npizza<hr><ul0>A<ul0>B<text>"
);
assert_matches(
buffer,
&SearchConfig {
query: r"a\b",
case_sensitive: false,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 2, "a"), (10, 11, "a"), (16, 17, "a"), (19, 20, "A")],
);
});
});
}
#[test]
fn test_no_match_across_block_items() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"word\n***\nword",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(buffer.debug(), "<text>word<hr><text>word");
assert_matches(
buffer,
&SearchConfig {
query: "word",
regex: false,
case_sensitive: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 5, "word"), (7, 11, "word")],
);
assert_no_matches(
buffer,
&SearchConfig {
query: r"word.*word",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
);
});
});
}
#[test]
fn test_block_boundaries_as_whitespace() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"nee\ndle nee\n1. dle\n```rust\nnee\n```\n```sh\ndle\n```",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(
buffer.debug(),
r"<text>nee\ndle nee<ol0@1>dle<code:Rust>nee<code:Shell>dle<text>"
);
let expected_matches = [
(1, 8, "nee\ndle"),
(9, 16, "nee\ndle"),
(17, 24, "nee\ndle"),
];
assert_matches(
buffer,
&SearchConfig {
query: r"nee\ndle",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
expected_matches,
);
// The `s` flag is needed for `.` to match newlines.
assert_no_matches(
buffer,
&SearchConfig {
query: r"nee.dle",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
);
assert_matches(
buffer,
&SearchConfig {
query: r"(?s)nee.dle",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
expected_matches,
);
});
});
}
#[test]
fn test_anchors() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"word\nsword\nword\nwords\nword",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(buffer.debug(), r"<text>word\nsword\nword\nwords\nword");
// This fails because of the initial block marker.
assert_no_matches(buffer, &SearchConfig::regex(r"\Aword\z"));
// This succeeds because there's no ending block marker.
assert_matches(buffer, &SearchConfig::regex(r"^word\z"), [(23, 27, "word")]);
assert_matches(
buffer,
&SearchConfig::regex("^word$"),
[(1, 5, "word"), (12, 16, "word"), (23, 27, "word")],
);
assert_matches(
buffer,
&SearchConfig::regex("word$"),
[
(1, 5, "word"),
(7, 11, "word"),
(12, 16, "word"),
(23, 27, "word"),
],
);
assert_matches(
buffer,
&SearchConfig::regex("^word"),
[
(1, 5, "word"),
(12, 16, "word"),
(17, 21, "word"),
(23, 27, "word"),
],
);
});
});
}
#[test]
fn test_skip_hidden_content() {
let mut buffer = SumTree::new();
buffer.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
buffer.append_str("before\nword\nafter");
let mut range_set = RangeSet::new();
range_set.insert(CharOffset::from(8)..CharOffset::from(12));
// With skip_hidden: false, finds text in hidden regions
let mut engine = Engine::new(&SearchConfig {
query: "word",
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: Some(&range_set),
})
.unwrap();
let matches = engine.find_blocking(&buffer, CharOffset::zero()).unwrap();
assert_eq!(matches.len(), 1);
// With skip_hidden: true, does not find text in hidden regions
let mut engine = Engine::new(&SearchConfig {
query: "word",
case_sensitive: true,
regex: false,
skip_hidden: true,
hidden_ranges: Some(&range_set),
})
.unwrap();
let matches = engine.find_blocking(&buffer, CharOffset::zero()).unwrap();
assert_eq!(matches.len(), 0);
}
#[test]
fn test_cooperative_search() {
// Manually construct a giant buffer for testing.
let mut buffer = SumTree::new();
buffer.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
for _ in 0..2000 {
buffer.append_str("ab");
}
let mut engine = Engine::new(&SearchConfig {
query: "a",
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: None,
})
.unwrap();
let mut search_future = pin!(engine.find(&buffer, CharOffset::zero()));
// With 2000 matches, the search should need 3 polls.
future::block_on(async move {
assert!(future::poll_once(&mut search_future).await.is_none());
assert!(future::poll_once(&mut search_future).await.is_none());
let result = future::poll_once(&mut search_future).await;
match result {
Some(Ok(matches)) => assert_eq!(matches.len(), 2000),
Some(Err(err)) => panic!("Search failed: {err}"),
None => panic!("Expected search to complete"),
}
});
}
fn init_logging() {
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = env_logger::builder().is_test(true).try_init();
})
}
/// Asserts that running `search` against `buffer` produces the expected matches.
fn assert_matches<'a>(
buffer: &Buffer,
search: &SearchConfig,
expected_matches: impl IntoIterator<Item = impl Into<ExpectedMatch<'a>>>,
) {
init_logging();
let mut engine = Engine::new(search).expect("Could not compile search");
let matches = engine
.find_blocking(&buffer.content, CharOffset::zero())
.expect("Could not run search")
.into_iter()
.map(|m| {
let text = buffer.text_in_range(m.start..m.end).into_string();
(m, text)
})
.collect_vec();
let expected_matches = expected_matches.into_iter().map(Into::into).collect_vec();
assert_eq!(
expected_matches, matches,
"Incorrect search results for {search:?}"
);
}
/// Asserts that `search` has no matches in `buffer`.
fn assert_no_matches(buffer: &Buffer, search: &SearchConfig) {
assert_matches(
buffer,
search,
iter::empty::<(usize, usize, &'static str)>(),
);
}
/// Helper for comparing to expected match results - this is needed because `String` and `&str`
/// aren't directly comparable.
#[derive(Debug)]
struct ExpectedMatch<'a> {
start: CharOffset,
end: CharOffset,
match_text: &'a str,
}
impl<'a> From<(usize, usize, &'a str)> for ExpectedMatch<'a> {
fn from((start, end, match_text): (usize, usize, &'a str)) -> Self {
Self {
start: start.into(),
end: end.into(),
match_text,
}
}
}
impl PartialEq<(Match, String)> for ExpectedMatch<'_> {
fn eq(&self, (other_offsets, other_text): &(Match, String)) -> bool {
self.start == other_offsets.start
&& self.end == other_offsets.end
&& self.match_text == other_text
}
}
@@ -0,0 +1,285 @@
use std::collections::HashMap;
use std::ops::Range;
use rangemap::RangeSet;
use string_offset::CharOffset;
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
use crate::content::edit::EditDelta;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::version::BufferVersion;
use crate::content::{buffer::Buffer, text::LineCount};
use super::anchor::{Anchor, AnchorSide};
/// 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.
///
/// Hidden ranges are stored as anchor pairs that automatically adjust their positions
/// when the buffer content changes.
pub struct HiddenLinesModel {
hidden_ranges: Vec<(Anchor, Anchor)>,
buffer: ModelHandle<Buffer>,
buffer_selections: ModelHandle<BufferSelectionModel>,
version_offsets: HashMap<BufferVersion, RangeSet<CharOffset>>,
}
impl HiddenLinesModel {
pub fn new(
buffer: ModelHandle<Buffer>,
buffer_selections: ModelHandle<BufferSelectionModel>,
) -> Self {
Self {
hidden_ranges: Vec::new(),
buffer,
buffer_selections,
version_offsets: HashMap::new(),
}
}
pub fn set_hidden_lines(&mut self, ranges: RangeSet<LineCount>, ctx: &mut ModelContext<Self>) {
self.hidden_ranges.clear();
let buffer = self.buffer.as_ref(ctx);
// We have to collect here given otherwise we will hold an immutable borrow to Buffer. And below we need a
// mutable reference to the ctx.
let offset_ranges: Vec<Range<CharOffset>> = ranges
.into_iter()
.map(|range| {
let start_offset = buffer.line_start(range.start + LineCount::from(1));
let end_offset = buffer.line_start(range.end + LineCount::from(1));
start_offset..end_offset
})
.collect();
let buffer_version = buffer.buffer_version();
for range in offset_ranges {
if range.start < range.end {
let (start_anchor, end_anchor) =
self.buffer_selections.update(ctx, |selection_model, _| {
let start_anchor = selection_model
.anchors
.create_anchor(range.start, AnchorSide::Right);
// Note that we anchor to the left of the _next line_ after the hidden range.
// This avoids any insert after the hidden range from expanding it.
let end_anchor = selection_model
.anchors
.create_anchor(range.end, AnchorSide::Left);
(start_anchor, end_anchor)
});
self.hidden_ranges.push((start_anchor, end_anchor));
}
}
self.materialize_hidden_range_offsets(buffer_version, ctx);
}
/// Check if the given offset is within a hidden range
pub fn is_hidden(&self, offset: CharOffset, ctx: &AppContext) -> bool {
for (start_anchor, end_anchor) in &self.hidden_ranges {
if let (Some(start), Some(end)) = (
self.buffer_selections
.as_ref(ctx)
.resolve_anchor(start_anchor),
self.buffer_selections
.as_ref(ctx)
.resolve_anchor(end_anchor),
) && offset >= start
&& offset < end
{
return true;
}
}
false
}
/// Check if there are materialized offsets for a given buffer version.
pub fn has_offsets_for_version(&self, version: BufferVersion) -> bool {
self.version_offsets.contains_key(&version)
}
/// For a given content version, check if the incoming offset ranges intersect with any of the
/// hidden ranges.
pub fn range_intersects_with_hidden_range_at_version(
&self,
range: &Range<CharOffset>,
version: BufferVersion,
) -> bool {
// If there is no hidden range set, default to false.
let Some(ranges) = self.version_offsets.get(&version) else {
return false;
};
ranges.overlaps(range)
}
/// Convert anchors (stateful) to offsets (stateless) for a given buffer version. This allows
/// us to maintain a stable hidden line range for each buffer state.
pub fn materialize_hidden_range_offsets(
&mut self,
version: BufferVersion,
ctx: &mut ModelContext<Self>,
) {
let hidden_ranges = self.anchors_to_offsets(ctx);
self.version_offsets.insert(version, hidden_ranges);
}
/// Set the following hidden range to be visible. No-op if the range is not hidden.
/// IMPORTANT: This assumes the line range only overlaps with one hidden range for efficiency.
pub fn set_visible_line_range(
&mut self,
line_range: Range<LineCount>,
ctx: &mut ModelContext<Self>,
) -> Option<EditDelta> {
let starting_visible = self.buffer.as_ref(ctx).line_start(line_range.start);
let buffer_version = self.buffer.as_ref(ctx).buffer_version();
let ending_visible = self
.buffer
.as_ref(ctx)
.line_start(line_range.end + LineCount::from(1));
let mut to_remove = None;
let mut delta = None;
for (idx, (start_anchor, end_anchor)) in self.hidden_ranges.iter().enumerate() {
let Some(start) = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(start_anchor)
else {
continue;
};
let Some(end) = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(end_anchor)
else {
continue;
};
// I know this seems inefficient to start...why can't we just invalidate starting_visible to ending_visible?
// This is because of a key constraint in the rendering layer. When we layout the blocks, we expect the boundary
// of what we invalidate to be on the exact boundary of a block. However, with hidden blocks, we collapse them into
// one block in the SumTree. This means we will end up in an invalid state if we try to slice in the middle of that
// giant hidden block. By making sure the invalidation range is min(start, starting_visible)..max(end, ending_visible),
// this works around that constraint. And it shouldn't be much more expensive as we don't layout hidden blocks.
let invalidation_range = start.min(starting_visible)..end.max(ending_visible);
if start >= starting_visible && end <= ending_visible {
to_remove = Some(idx);
delta = Some(
self.buffer
.as_ref(ctx)
.invalidate_layout_for_range(invalidation_range),
);
break;
}
// We can early break since the visible range can only overlap with one hidden range.
if start < starting_visible && end > starting_visible {
self.buffer_selections.update(ctx, |selections, _| {
selections
.anchors
.update_anchor(end_anchor, starting_visible);
});
delta = Some(
self.buffer
.as_ref(ctx)
.invalidate_layout_for_range(invalidation_range),
);
break;
} else if ending_visible > start && end > ending_visible {
self.buffer_selections.update(ctx, |selections, _| {
selections
.anchors
.update_anchor(start_anchor, ending_visible);
});
delta = Some(
self.buffer
.as_ref(ctx)
.invalidate_layout_for_range(invalidation_range),
);
break;
}
}
if let Some(idx) = to_remove {
self.hidden_ranges.remove(idx);
}
self.materialize_hidden_range_offsets(buffer_version, ctx);
delta
}
/// Check if a character offset range contains any hidden sections.
pub fn contains_hidden_section(&self, range: &Range<CharOffset>, ctx: &AppContext) -> bool {
self.is_hidden(range.start, ctx)
|| self.is_hidden(range.end, ctx)
|| self
.hidden_ranges_at_latest(ctx)
.iter()
.any(|hidden_range| {
hidden_range.start < range.end && hidden_range.end > range.start
})
}
/// Check if any selection head is immediately after a hidden section.
pub fn after_hidden_section(&self, ctx: &AppContext) -> bool {
let selections = self.buffer_selections.as_ref(ctx).selection_heads();
let hidden_ranges = self.hidden_ranges_at_latest(ctx);
selections
.iter()
.any(|&head| hidden_ranges.iter().any(|range| range.end == head))
}
/// Check if any selection head is immediately before a hidden section.
pub fn before_hidden_section(&self, ctx: &AppContext) -> bool {
let selections = self.buffer_selections.as_ref(ctx).selection_heads();
let hidden_ranges = self.hidden_ranges_at_latest(ctx);
selections
.iter()
.any(|&head| hidden_ranges.iter().any(|range| range.start == head))
}
pub fn hidden_ranges_at_version(&self, version: BufferVersion) -> RangeSet<CharOffset> {
self.version_offsets
.get(&version)
.cloned()
.unwrap_or_default()
}
pub fn hidden_ranges_at_latest(&self, ctx: &AppContext) -> RangeSet<CharOffset> {
let latest_version = self.buffer.as_ref(ctx).buffer_version();
self.hidden_ranges_at_version(latest_version)
}
/// Get all current hidden ranges as resolved offsets
fn anchors_to_offsets(&self, ctx: &mut ModelContext<Self>) -> RangeSet<CharOffset> {
self.hidden_ranges
.iter()
.filter_map(|(start_anchor, end_anchor)| {
let start = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(start_anchor)?;
let end = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(end_anchor)?;
if start < end { Some(start..end) } else { None }
})
.collect()
}
}
impl Entity for HiddenLinesModel {
type Event = ();
}
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
use std::sync::Arc;
use markdown_parser::{compute_formatted_text_delta, parse_markdown};
use serde_yaml::Value;
use string_offset::CharOffset;
use vec1::Vec1;
use warpui::{App, ReadModel};
use crate::content::{
buffer::{
Buffer, BufferEditAction, EditOrigin, StyledBlockBoundaryBehavior, tests::TestEmbeddedItem,
},
text::{IndentBehavior, TABLE_BLOCK_MARKDOWN_LANG},
};
use super::MarkdownStyle;
#[test]
fn test_export_normalizes_code_languages() {
let formatted = parse_markdown(
r#"
```JavaScript
console.log("Hello, World");
```
```Rust
println!("Hello, World");
```
```ocaml
print_endline "Hello, World!"
```
"#,
)
.unwrap();
let exported = Buffer::export_to_markdown(
formatted,
None,
MarkdownStyle::Export {
app_context: None,
should_not_escape_markdown_punctuation: false,
},
);
// Exporting should use external code languages.
assert_eq!(
exported,
r#"
```js
console.log("Hello, World");
```
```rust
println!("Hello, World");
```
```ocaml
print_endline "Hello, World!"
```
"#
);
}
#[test]
fn test_mermaid_markdown_round_trip() {
App::test((), |mut app| async move {
let _flag = warp_core::features::FeatureFlag::MarkdownMermaid.override_enabled(true);
let markdown = "```mermaid\ngraph TD\nA --> B\n```\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let internal_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown());
assert_eq!(internal_markdown, markdown);
let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert_eq!(exported_markdown, markdown);
});
}
#[test]
fn test_export_expands_embeds() {
// This tests styled block for the edge case of querying just the
// leading block item (0..1).
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
r#"
```warp-embedded-object
id: embed-123
```
```warp-embedded-object
id: embed-456
ignored: value
```"#,
Some(|mut mapping| match mapping.remove(&"id".into()) {
Some(Value::String(id)) => Some(Arc::new(TestEmbeddedItem { id })),
_ => None,
}),
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let exported = app.read_model(&buffer, |buffer, _| {
buffer.to_markdown(MarkdownStyle::Export {
app_context: None,
should_not_escape_markdown_punctuation: false,
})
});
// Exporting should expand the embedded objects.
assert_eq!(
exported,
r#"
```warp-embedded-object
---
id: embed-123
export: true
```
```warp-embedded-object
---
id: embed-456
export: true
```
"#
);
});
}
#[test]
fn test_table_html_serialization() {
App::test((), |mut app| async move {
let markdown = format!(
"```{}\nheader 1\theader 2\nvalue 1\tvalue 2\n```\n",
TABLE_BLOCK_MARKDOWN_LANG
);
let (buffer, _selection) = Buffer::mock_from_markdown(
&markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains(
"<table><thead><tr><th align=\"left\">header 1</th><th align=\"left\">header 2</th></tr></thead><tbody><tr><td align=\"left\">value 1</td><td align=\"left\">value 2</td></tr></tbody></table>"
));
});
}
#[test]
fn test_gfm_table_html_serialization() {
App::test((), |mut app| async move {
let _flag = warp_core::features::FeatureFlag::MarkdownTables.override_enabled(true);
let markdown = "\
| header 1 | header 2 |\n\
| --- | --- |\n\
| value 1 | value 2 |\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains(
"<table><thead><tr><th align=\"left\">header 1</th><th align=\"left\">header 2</th></tr></thead><tbody><tr><td align=\"left\">value 1</td><td align=\"left\">value 2</td></tr></tbody></table>"
));
});
}
#[test]
fn test_apply_formatted_text_delta_append() {
App::test((), |mut app| async move {
let old_markdown = "hello world\n";
let (buffer, selection) = Buffer::mock_from_markdown(
old_markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
// Buffer::mock_from_markdown removes the trailing newline, so add it back.
buffer.update(&mut app, |buffer, ctx| {
let end_offset = buffer.max_charoffset();
let edits =
Vec1::try_from_vec(vec![("\n".to_string(), end_offset..end_offset)]).unwrap();
buffer.update_content(
BufferEditAction::InsertAtCharOffsetRanges { edits: &edits },
EditOrigin::SystemEdit,
selection.clone(),
ctx,
);
});
let (exported, old_formatted) = app.read_model(&buffer, |buffer, _| {
let old_formatted = buffer.range_to_formatted_text(
CharOffset::from(1)..buffer.max_charoffset(),
StyledBlockBoundaryBehavior::Exclusive,
);
(buffer.markdown_unescaped(), old_formatted)
});
assert_eq!(exported, "hello world\n");
let new_markdown = "hello world\n#";
let new_formatted = parse_markdown(new_markdown).unwrap();
let delta = compute_formatted_text_delta(old_formatted, new_formatted.clone());
// Should just be appending a new line
assert_eq!(delta.common_prefix_lines, 1);
// There's a trailing linebreak being replaced
assert_eq!(delta.old_suffix_formatted_text_lines, 1);
assert_eq!(delta.new_suffix.len(), 1);
buffer.update(&mut app, |buffer, ctx| {
buffer.apply_formatted_text_delta(&delta, selection.clone(), ctx);
});
let (exported, formatted_in_buffer) = app.read_model(&buffer, |buffer, _| {
let new_formatted = buffer.range_to_formatted_text(
CharOffset::from(1)..buffer.max_charoffset(),
StyledBlockBoundaryBehavior::Exclusive,
);
(buffer.markdown_unescaped(), new_formatted)
});
assert_eq!(exported, new_markdown);
assert_eq!(new_formatted, formatted_in_buffer);
let new_markdown_2 = "hello world\n# This is a heading";
let new_formatted_2 = parse_markdown(new_markdown_2).unwrap();
let delta_2 = compute_formatted_text_delta(new_formatted, new_formatted_2.clone());
// Should be replacing the # line while keeping the hello world line
assert_eq!(delta_2.common_prefix_lines, 1);
assert_eq!(delta_2.old_suffix_formatted_text_lines, 1);
assert_eq!(delta_2.new_suffix.len(), 1);
buffer.update(&mut app, |buffer, ctx| {
buffer.apply_formatted_text_delta(&delta_2, selection.clone(), ctx);
});
let (exported, formatted_in_buffer) = app.read_model(&buffer, |buffer, _| {
let new_formatted = buffer.range_to_formatted_text(
CharOffset::from(1)..buffer.max_charoffset(),
StyledBlockBoundaryBehavior::Exclusive,
);
(buffer.markdown_unescaped(), new_formatted)
});
// We add a trailing newline
assert_eq!(exported.trim_end(), new_markdown_2);
assert_eq!(new_formatted_2, formatted_in_buffer);
});
}
#[test]
fn test_image_html_serialization() {
App::test((), |mut app| async move {
let markdown = "![Alt text](image.png)\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
// Image should be serialized as <img src="image.png" alt="Alt text" />
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains("<img"));
assert!(html.contains("src=\"image.png\""));
assert!(html.contains("alt=\"Alt text\""));
});
}
#[test]
fn test_multiple_images_html_serialization() {
App::test((), |mut app| async move {
let markdown = "![First](./path/img1.jpg)\n![Second](https://example.com/img2.png)\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
// Check both images are in the HTML
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains("src=\"./path/img1.jpg\""));
assert!(html.contains("alt=\"First\""));
assert!(html.contains("src=\"https://example.com/img2.png\""));
assert!(html.contains("alt=\"Second\""));
});
}
#[test]
fn test_table_markdown_round_trip() {
App::test((), |mut app| async move {
let markdown = format!(
"```{}\nheader 1\theader 2\nvalue 1\tvalue 2\n```\n",
TABLE_BLOCK_MARKDOWN_LANG
);
let (buffer, _selection) = Buffer::mock_from_markdown(
&markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let internal_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown());
assert_eq!(internal_markdown, markdown);
let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert_eq!(
exported_markdown,
"| header 1 | header 2 |\n| --- | --- |\n| value 1 | value 2 |\n"
);
});
}
#[test]
fn test_table_markdown_export_escapes_pipe_characters() {
App::test((), |mut app| async move {
let markdown = format!(
"```{}\nhead|er 1\theader 2\nvalue | 1\tvalue 2\n```\n",
TABLE_BLOCK_MARKDOWN_LANG
);
let (buffer, _selection) = Buffer::mock_from_markdown(
&markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert_eq!(
exported_markdown,
"| head\\|er 1 | header 2 |\n| --- | --- |\n| value \\| 1 | value 2 |\n"
);
});
}
#[test]
fn test_image_with_content_html_serialization() {
App::test((), |mut app| async move {
let markdown = "# Header\n\n![Image](test.png)\n\nSome text\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
// Check that header, image, and text are all present
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains("<h1>"));
assert!(html.contains("Header"));
assert!(html.contains("<img"));
assert!(html.contains("src=\"test.png\""));
assert!(html.contains("Some text"));
});
}
@@ -0,0 +1,91 @@
use std::{
hash::{DefaultHasher, Hash, Hasher},
sync::Arc,
};
use bytes::Bytes;
use mermaid_to_svg::MermaidTheme;
use warpui::{
AppContext, SingletonEntity,
assets::asset_cache::{AssetCache, AssetSource, AssetState, AsyncAssetId, AsyncAssetType},
image_cache::ImageType,
units::{IntoPixels, Pixels},
};
use crate::render::{
layout::TextLayout,
model::{BlockSpacing, ImageBlockConfig},
};
const DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER: f32 = 10.0;
struct MermaidDiagramAsset;
impl AsyncAssetType for MermaidDiagramAsset {}
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 fetch_source = source.clone();
AssetSource::Async {
id: AsyncAssetId::new::<MermaidDiagramAsset>(id),
fetch: Arc::new(move || {
let source = fetch_source.clone();
Box::pin(async move {
mermaid_to_svg::render_mermaid_to_svg(&source, Some(&MermaidTheme::light()))
.map(|svg| Bytes::from(svg.into_bytes()))
.map_err(Into::into)
})
}),
}
}
pub fn mermaid_diagram_layout(
source: &str,
layout: &TextLayout,
spacing: BlockSpacing,
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));
(
asset_source,
ImageBlockConfig {
width,
height,
spacing,
},
)
}
fn mermaid_diagram_size(
asset_source: &AssetSource,
max_width: Pixels,
app: &AppContext,
) -> Option<(Pixels, Pixels)> {
let asset_cache = AssetCache::as_ref(app);
let AssetState::Loaded { data } = asset_cache.load_asset::<ImageType>(asset_source.clone())
else {
return None;
};
let ImageType::Svg { svg } = data.as_ref() else {
return None;
};
let intrinsic_size = svg.size();
let intrinsic_width = intrinsic_size.width();
let intrinsic_height = intrinsic_size.height();
if intrinsic_width <= 0. || intrinsic_height <= 0. {
return None;
}
let width = Pixels::new(max_width.as_f32().min(intrinsic_width));
let height = Pixels::new(width.as_f32() * intrinsic_height / intrinsic_width);
Some((width, height))
}
+18
View File
@@ -0,0 +1,18 @@
pub mod anchor;
pub mod buffer;
pub mod core;
mod cursor;
pub mod diff;
pub mod edit;
pub mod find;
pub mod hidden_lines_model;
pub mod markdown;
pub mod mermaid_diagram;
pub mod outline;
mod segmentation;
pub mod selection;
pub mod selection_model;
pub mod text;
pub mod undo;
mod validation;
pub mod version;
+93
View File
@@ -0,0 +1,93 @@
use sum_tree::{Cursor, SeekBias};
use crate::content::text::BlockType;
use string_offset::CharOffset;
use super::{
buffer::Buffer,
text::{BlockCount, BufferBlockStyle, BufferText},
};
#[cfg(test)]
#[path = "outline_tests.rs"]
mod tests;
/// Outline of a block within the buffer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockOutline {
/// Offset of the block's start marker.
pub start: CharOffset,
/// Exclusive end of the block - the block marker that ends it.
pub end: CharOffset,
/// Style identifying the kind of block this is.
pub block_type: BlockType,
}
/// Iterator over the blocks within a buffer.
struct BlockOutlines<'a> {
count: BlockCount,
cursor: Cursor<'a, BufferText, BlockCount, CharOffset>,
}
impl Buffer {
/// Outlines the blocks in this buffer.
///
/// This supports quickly finding blocks without the overhead of parsing all style and run
/// information. It's useful for indexing (e.g. to show a table of contents) and building
/// per-block state.
pub fn outline_blocks(&self) -> impl Iterator<Item = BlockOutline> + '_ {
BlockOutlines {
cursor: self.content.cursor(),
count: BlockCount::from(1),
}
}
}
impl Iterator for BlockOutlines<'_> {
type Item = BlockOutline;
fn next(&mut self) -> Option<Self::Item> {
// The start marker for the current block will be at the cursor location where the block
// count changes from `self.count - 1` to `self.count`.
if !self.cursor.seek(&self.count, SeekBias::Left) {
return None;
};
while let Some(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
}) = self.cursor.item()
{
self.count += 1;
let found = self.cursor.seek(&self.count, SeekBias::Left);
if !found {
return None;
}
}
let block_type = match self.cursor.item()? {
BufferText::BlockMarker { marker_type }
if *marker_type != BufferBlockStyle::PlainText =>
{
BlockType::Text(marker_type.clone())
}
BufferText::BlockItem { item_type } => BlockType::Item(item_type.clone()),
other => {
panic!("Invalid cursor state, expected a block-start marker but got {other:?}")
}
};
let start_offset = *self.cursor.start();
// The end of the current block is where the next one begins, so the count increases by 1.
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.
self.count = end_count;
Some(BlockOutline {
start: start_offset,
end: *self.cursor.start(),
block_type,
})
}
}
+225
View File
@@ -0,0 +1,225 @@
use itertools::Itertools;
use crate::content::{
buffer::Buffer,
outline::BlockOutline,
selection_model::BufferSelectionModel,
text::{BlockType, BufferBlockStyle, IndentBehavior, TextStyles},
};
use string_offset::CharOffset;
use warpui::App;
#[test]
fn test_no_blocks() {
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| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"regular text",
TextStyles::default(),
selection.clone(),
ctx,
);
assert_eq!(buffer.outline_blocks().count(), 0);
});
});
}
#[test]
fn test_block_at_start() {
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| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"BlockText",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(1)..CharOffset::from(6),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<code:Shell>Block<text>Text");
let outline = buffer.outline_blocks().collect_vec();
assert_eq!(
outline,
vec![BlockOutline {
start: CharOffset::from(0),
end: CharOffset::from(6),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}]
)
});
});
}
#[test]
fn test_block_at_end() {
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| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"TextBlock",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(5)..CharOffset::from(10),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<text>Text<code:Shell>Block<text>");
let outline = buffer.outline_blocks().collect_vec();
assert_eq!(
outline,
vec![BlockOutline {
start: CharOffset::from(5),
end: CharOffset::from(11),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}]
)
});
});
}
#[test]
fn test_multiple_blocks() {
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| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"textFirsttextSecondtext",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(14)..CharOffset::from(20),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(5)..CharOffset::from(10),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<text>text<code:Shell>First<text>text<code:Shell>Second<text>text"
);
assert_eq!(
buffer.outline_blocks().collect_vec(),
vec![
BlockOutline {
start: CharOffset::from(5),
end: CharOffset::from(11),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
},
BlockOutline {
start: CharOffset::from(16),
end: CharOffset::from(23),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}
]
)
});
});
}
#[test]
fn test_adjacent_blocks() {
// This is a regression test for when two blocks of the same kind are adjacent to each other.
// This can occur when inserting a block right before another of the same kind.
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| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"textFirstSecondtext",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(10)..CharOffset::from(16),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(5)..CharOffset::from(10),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<text>text<code:Shell>First<code:Shell>Second<text>text"
);
assert_eq!(
buffer.outline_blocks().collect_vec(),
vec![
BlockOutline {
start: CharOffset::from(5),
end: CharOffset::from(11),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
},
BlockOutline {
start: CharOffset::from(11),
end: CharOffset::from(18),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}
]
);
});
});
}
+144
View File
@@ -0,0 +1,144 @@
//! Text segmentation (splitting on words and grapheme clusters) for rich-text buffers.
//! Mostly, this relies on the [`crate::text::word_boundaries`] module by implementing the
//! [`TextBuffer`] API.
use anyhow::anyhow;
use string_offset::CharOffset;
use warpui::text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy};
use super::{
buffer::{Buffer, ToBufferCharOffset, ToBufferPoint},
cursor::BufferCursor,
};
#[cfg(test)]
#[path = "segmentation_tests.rs"]
mod tests;
impl Buffer {
/// Get the offset of the start of the word closest to the given position.
pub fn word_start(&self, offset: CharOffset, policy: &WordBoundariesPolicy) -> CharOffset {
self.word_starts_backward_from_offset_inclusive(offset)
.ok()
.and_then(|word_starts| word_starts.with_policy(policy).next())
.map(|point| point.to_buffer_char_offset(self))
.unwrap_or(offset)
}
/// Get the offset of the end of the word closest to the given position.
pub fn word_end(&self, offset: CharOffset, policy: &WordBoundariesPolicy) -> CharOffset {
self.word_ends_from_offset_inclusive(offset)
.ok()
.and_then(|word_ends| word_ends.with_policy(policy).next())
.map(|point| point.to_buffer_char_offset(self))
.unwrap_or(offset)
}
}
impl TextBuffer for Buffer {
type Chars<'a>
= Chars<'a>
where
Self: 'a;
type CharsReverse<'a>
= Chars<'a>
where
Self: 'a;
fn chars_at(&self, offset: CharOffset) -> anyhow::Result<Self::Chars<'_>> {
Chars::new(self, offset)
}
fn chars_rev_at(&self, offset: CharOffset) -> anyhow::Result<Self::CharsReverse<'_>> {
Chars::new_reversed(self, offset)
}
fn to_point(&self, offset: CharOffset) -> anyhow::Result<Point> {
Ok(offset.to_buffer_point(self))
}
fn to_offset(&self, point: Point) -> anyhow::Result<CharOffset> {
Ok(point.to_buffer_char_offset(self))
}
}
pub struct Chars<'a> {
/// Underlying cursor into the buffer.
cursor: BufferCursor<'a, CharOffset>,
/// Whether we're iterating in the reverse direction.
reversed: bool,
is_first: bool,
}
impl<'a> Chars<'a> {
/// Returns a new char iterator starting at `offset`, or an error if the offset is out of bounds.
fn new(buffer: &'a Buffer, offset: CharOffset) -> anyhow::Result<Self> {
if offset > buffer.max_charoffset() {
return Err(anyhow!("char offset {offset} out of bounds"));
}
let cursor = buffer.content.cursor();
let mut buffer_cursor = BufferCursor::new(cursor);
buffer_cursor.seek_to_offset_before_markers(offset);
Ok(Self {
cursor: buffer_cursor,
reversed: false,
is_first: true,
})
}
/// Returns a new reversed char iterator starting at `offset`, or an error if the offset is out
/// of bounds.
fn new_reversed(buffer: &'a Buffer, offset: CharOffset) -> anyhow::Result<Self> {
if offset > buffer.max_charoffset() {
return Err(anyhow!("char offset {offset} out of bounds"));
}
let cursor = buffer.content.cursor();
let mut buffer_cursor = BufferCursor::new(cursor);
buffer_cursor.seek_to_offset_after_markers(offset);
Ok(Self {
cursor: buffer_cursor,
reversed: true,
is_first: true,
})
}
/// Move the underlying cursor to the next item (in the current direction).
fn advance(&mut self) {
// If this is the first `next` call and the direction is not reversed,
// we don't need to advance the cursor as it is already in the right position.
if self.is_first && !self.reversed {
self.is_first = false;
return;
}
if self.reversed {
self.cursor.prev_char_position();
} else {
self.cursor.next_char_position();
}
}
}
impl Iterator for Chars<'_> {
type Item = char;
fn next(&mut self) -> Option<Self::Item> {
loop {
self.advance();
if self.cursor.item().is_none() {
break None;
}
match self.cursor.char() {
// This treats placeholders as invisible, like style markers. We may want to return
// a fake boundary character instead so that placeholders split word boundaries.
None => continue,
Some(character) => break Some(character),
}
}
}
}
@@ -0,0 +1,231 @@
use itertools::Itertools;
use markdown_parser::parse_markdown;
use string_offset::CharOffset;
use warp_core::features::FeatureFlag;
use crate::content::{
buffer::{Buffer, EditOrigin},
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use warpui::{
App,
text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy},
};
#[test]
fn test_forward_iteration() {
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| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("```\nText\n```\n**bold**\nAnd *italic* too.")
.expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<code:Shell>Text<text><b_s>bold<b_e>\\nAnd <i_s>italic<i_e> too."
);
let mut chars = buffer.chars_at(CharOffset::from(1)).expect("Offset valid");
// Block markers are converted to whitespace.
assert_eq!(chars.next(), Some('T'));
assert_eq!(chars.next(), Some('e'));
assert_eq!(chars.next(), Some('x'));
assert_eq!(chars.next(), Some('t'));
assert_eq!(chars.next(), Some('\n'));
// This transparently skips over the style markers.
assert_eq!(chars.next(), Some('b'));
// We should also be able to start from partway through.
let chars = buffer
.chars_at(CharOffset::from(12))
.expect("Offset valid")
.collect_vec();
assert_eq!(
chars,
vec![
'n', 'd', ' ', 'i', 't', 'a', 'l', 'i', 'c', ' ', 't', 'o', 'o', '.'
]
);
});
});
}
#[test]
fn test_table_word_boundaries_include_full_cell_text() {
App::test((), |mut app| async move {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let (buffer, _selection) = Buffer::mock_from_markdown(
"| Hello | Value |\n| --- | --- |\n| World | Cell |\n",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
let chars = buffer
.chars_at(CharOffset::from(1))
.expect("Offset valid")
.take(12)
.collect_vec();
assert_eq!(
chars,
vec!['H', 'e', 'l', 'l', 'o', '\t', 'V', 'a', 'l', 'u', 'e', '\n']
);
let policy = WordBoundariesPolicy::Default;
let start = buffer.word_start(CharOffset::from(1), &policy);
let end = buffer.word_end(CharOffset::from(1), &policy);
assert_eq!(start, CharOffset::from(1));
assert_eq!(buffer.text_in_range(start..end).into_string(), "Hello");
});
});
}
#[test]
fn test_start_styled() {
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| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("*styled* text").expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
let mut chars = buffer.chars_at(CharOffset::from(1)).expect("Offset valid");
assert_eq!(chars.next(), Some('s'));
assert_eq!(chars.next(), Some('t'));
});
});
}
#[test]
fn test_reverse_iteration() {
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| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("some *text*").expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
let chars = buffer
.chars_rev_at(CharOffset::from(4))
.expect("Offset valid")
.collect_vec();
assert_eq!(chars, vec!['m', 'o', 's', '\n']);
let chars = buffer
.chars_rev_at(CharOffset::from(0))
.expect("Offset valid")
.collect_vec();
assert!(chars.is_empty());
let mut chars = buffer
.chars_rev_at(CharOffset::from(8))
.expect("Offset valid");
assert_eq!(chars.next(), Some('e'));
assert_eq!(chars.next(), Some('t'));
assert_eq!(chars.next(), Some(' '));
assert_eq!(chars.next(), Some('e'));
});
});
}
#[test]
fn test_plain_text_boundaries() {
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| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("this *is* plain\ntext").expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
let offsets = buffer
.word_starts_from_offset(CharOffset::from(3))
.unwrap()
.collect_vec();
assert_eq!(
offsets,
vec![
Point::new(1, 5),
Point::new(1, 8),
Point::new(2, 0),
Point::new(2, 4)
]
);
let ends_exclusive = buffer
.word_ends_from_offset_exclusive(CharOffset::from(5))
.unwrap()
.collect_vec();
// This should exclude the end of "this".
assert_eq!(
ends_exclusive,
vec![Point::new(1, 7), Point::new(1, 13), Point::new(2, 4)]
);
});
});
}
#[test]
fn test_empty_buffer() {
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.read(&app, |buffer, _| {
// Clearly out-of-bounds indices should fail.
assert!(buffer.chars_at(4.into()).is_err());
assert!(buffer.chars_rev_at(4.into()).is_err());
// Starting at 0, on the other hand, produces an empty iterator.
// All buffers implicitly contain a leading block marker. However, when segmenting words, the
// marker should not be included.
let mut chars = buffer.chars_at(CharOffset::zero()).expect("Can start at 0");
assert_eq!(chars.next(), Some('\n'));
let mut chars = buffer
.chars_rev_at(CharOffset::zero())
.expect("Can start at 0");
assert_eq!(chars.next(), None);
let mut words = buffer
.word_starts_from_offset(CharOffset::zero())
.expect("Can start at 0");
// Since the buffer is not truly empty (due to the leading block marker), WordBoundaries::next
// considers the end of the buffer a word boundary.
assert_eq!(words.next(), Some(Point::new(1, 0)));
// Likewise, when moving backwards, the start of the buffer is a word boundary.
let mut words_rev = buffer
.word_starts_backward_from_offset_exclusive(CharOffset::zero())
.expect("Can start at 0");
assert_eq!(words_rev.next(), Some(Point::new(0, 0)));
});
});
}
+154
View File
@@ -0,0 +1,154 @@
use vec1::Vec1;
use string_offset::CharOffset;
use super::anchor::{Anchor, AnchorSide, Anchors};
#[derive(Clone)]
pub struct Selection {
/// A head is where the cursor is and any arrow movement action only modifies the
/// head of a selection.
head: Anchor,
tail: Anchor,
bias: TextStyleBias,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextStyleBias {
InStyle,
#[default]
OutOfStyle,
}
impl Selection {
pub fn new(head: Anchor, tail: Anchor) -> Self {
Self {
head,
tail,
bias: TextStyleBias::OutOfStyle,
}
}
pub fn head(&self) -> &Anchor {
&self.head
}
pub fn bias(&self) -> TextStyleBias {
self.bias
}
pub fn tail(&self) -> &Anchor {
&self.tail
}
pub(super) fn set_head(&mut self, anchors: &mut Anchors, head: CharOffset) {
if anchors.resolve(&self.head).is_some() {
anchors.update_anchor(&self.head, head);
} else {
let anchor = anchors.create_anchor(head, AnchorSide::Right);
self.head = anchor;
}
}
pub(super) fn set_tail(&mut self, anchors: &mut Anchors, tail: CharOffset) {
if anchors.resolve(&self.tail).is_some() {
anchors.update_anchor(&self.tail, tail);
} else {
let anchor = anchors.create_anchor(tail, AnchorSide::Right);
self.tail = anchor;
}
}
pub fn set_bias(&mut self, bias: TextStyleBias) {
self.bias = bias;
}
}
/// All active selections in the editor.
///
/// Create a new selection set with a single selection. Note that there must
/// always be at least one selection in the set.
///
/// let selection_set = SelectionSet::new(selection);
#[derive(Clone)]
pub struct SelectionSet {
selections: Vec1<Selection>,
}
impl SelectionSet {
/// Create a new selection set with a single selection. Note that there must
/// always be at least one selection in the set.
pub fn new(selection: Selection) -> Self {
Self {
selections: Vec1::new(selection),
}
}
/// Return a reference to the first selection that was created.
pub fn first(&self) -> &Selection {
self.selections.first()
}
/// Return a mutable reference to the first selection that was created.
pub fn first_mut(&mut self) -> &mut Selection {
self.selections.first_mut()
}
pub fn last(&self) -> &Selection {
self.selections.last()
}
pub fn last_mut(&mut self) -> &mut Selection {
self.selections.last_mut()
}
/// Add a new selection to the set of selections.
pub fn push(&mut self, selection: Selection) {
self.selections.push(selection);
}
/// Remove all selections except the first one.
pub fn truncate(&mut self) {
self.selections
.truncate(1)
.expect("Truncating to literal 1 cannot fail");
}
pub fn len(&self) -> usize {
self.selections.len()
}
pub fn is_empty(&self) -> bool {
self.selections.is_empty()
}
/// Map a function over the selections in the set, returning a new Vec1 of the results.
pub fn selection_map<T, F>(&self, f: F) -> Vec1<T>
where
F: Fn(&Selection) -> T,
{
self.selections.mapped_ref(f)
}
pub fn iter(&self) -> impl Iterator<Item = &Selection> {
self.selections.iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Selection> {
self.selections.iter_mut()
}
}
impl From<Vec1<Selection>> for SelectionSet {
fn from(selections: Vec1<Selection>) -> SelectionSet {
SelectionSet { selections }
}
}
impl TryFrom<Vec<Selection>> for SelectionSet {
type Error = vec1::Size0Error;
fn try_from(selections: Vec<Selection>) -> Result<Self, Self::Error> {
Vec1::try_from(selections).map(SelectionSet::from)
}
}
@@ -0,0 +1,386 @@
use std::ops::Range;
use itertools::Itertools;
use string_offset::CharOffset;
use vec1::{Vec1, vec1};
use warpui::{AppContext, Entity, ModelHandle};
use crate::content::{
anchor::{Anchor, AnchorSide, AnchorUpdate, Anchors},
buffer::{Buffer, SelectionOffsets, ToBufferPoint},
selection::{Selection, SelectionSet},
text::{BlockType, TextStylesWithMetadata},
};
/// A snapshot of the selection state. This includes all data reported by [`BufferEvent::SelectionChanged`].
#[derive(PartialEq, Eq)]
pub(super) struct SelectionSnapshot {
/// We must snapshot the resolved selections, rather than their anchors, because anchors are
/// updated in-place and won't directly reflect the update.
pub(super) selections: Vec1<SelectionOffsets>,
pub(super) active_text_styles: TextStylesWithMetadata,
pub(super) active_block_type: BlockType,
}
pub struct BufferSelectionModel {
selections: SelectionSet,
pub(crate) anchors: Anchors,
buffer: ModelHandle<Buffer>,
}
impl BufferSelectionModel {
pub fn new(buffer: ModelHandle<Buffer>) -> Self {
let mut anchors = Anchors::new();
let head_anchor = anchors.create_anchor(CharOffset::from(1), AnchorSide::Right);
let tail_anchor = anchors.create_anchor(CharOffset::from(1), AnchorSide::Right);
let selections = SelectionSet::new(Selection::new(head_anchor, tail_anchor));
Self {
selections,
buffer,
anchors,
}
}
pub fn update_anchors(&mut self, anchor_updates: Vec<AnchorUpdate>) {
for update in anchor_updates {
self.anchors.update(update);
}
}
pub(super) fn truncate(&mut self) {
self.selections.truncate();
}
/// Create a new anchor at the given offset.
pub fn anchor(&mut self, offset: CharOffset, ctx: &AppContext) -> Anchor {
self.anchors
.create_anchor(offset.min(self.buffer.as_ref(ctx).len()), AnchorSide::Right)
}
pub(super) fn create_anchor(&mut self, offset: CharOffset, side: AnchorSide) -> Anchor {
self.anchors.create_anchor(offset, side)
}
/// Resolve an anchor to its current offset.
pub fn resolve_anchor(&self, anchor: &Anchor) -> Option<CharOffset> {
self.anchors.resolve(anchor)
}
/// Resolve an anchor to its 0-based line number in the buffer.
pub fn line_number_from_anchor(&self, anchor: &Anchor, ctx: &AppContext) -> Option<usize> {
let char_offset = self.resolve_anchor(anchor)?;
Some(char_offset.to_buffer_point(self.buffer.as_ref(ctx)).row as usize)
}
// The following two are temporary methods until multiple selections are supported.
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn selection(&self) -> &Selection {
self.selections.first()
}
pub fn selections(&self) -> &SelectionSet {
&self.selections
}
pub fn selections_mut(&mut self) -> &mut SelectionSet {
&mut self.selections
}
/// Returns the index of all lines that have active selection.
pub fn selected_lines(&self, ctx: &AppContext) -> Vec1<usize> {
Vec1::try_from_vec(
self.selections
.iter()
.flat_map(|s| {
let range = self.selection_to_offset_range(s);
let start_idx =
range.start.to_buffer_point(self.buffer.as_ref(ctx)).row as usize;
let end_idx = ((range.end - 1).to_buffer_point(self.buffer.as_ref(ctx)).row
as usize)
.max(start_idx);
start_idx..end_idx + 1
})
.sorted()
.dedup()
.collect_vec(),
)
.expect("Should have more than 1 element")
}
pub fn selection_to_offset_range(&self, selection: &Selection) -> Range<CharOffset> {
// Selection should always be valid when we have single selections.
// TODO(kevin): migrate this when moving to multi-selection.
let head = self
.resolve_anchor(selection.head())
.expect("anchor should exist");
let tail = self
.resolve_anchor(selection.tail())
.expect("anchor should exist");
if head >= tail { tail..head } else { head..tail }
}
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn selection_to_first_offset_range(&self) -> Range<CharOffset> {
self.selection_to_offset_range(self.selection())
}
pub fn selections_to_offset_ranges(&self) -> Vec1<Range<CharOffset>> {
self.selections
.selection_map(|s| self.selection_to_offset_range(s))
}
fn create_selection(&mut self, head: CharOffset, tail: CharOffset) -> Selection {
Selection::new(
self.create_anchor(head, AnchorSide::Right),
self.create_anchor(tail, AnchorSide::Right),
)
}
pub fn set_selections(&mut self, selections: SelectionSet) {
self.selections = selections;
}
/// Checks for overlapping selections. If any are found, we merge them together.
///
/// Before:
/// | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
/// | S1 | | S3 |
/// | S2 |
/// | S4 |
///
/// After:
/// | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
/// | S1 |
/// | S2 |
pub fn merge_overlapping_selections(&mut self) {
let (overlap_indices, new_ranges) =
Buffer::overlapping_ranges(self.selections_to_offset_ranges().to_vec());
// Return early if there are no overlapping selections.
if overlap_indices.is_empty() {
return;
}
// To determine whether the head of new selections should be before or after the tail,
// we check whether overlapping selections have the head before the tail.
// This seems to work in the common cases.
let forwards_selection = self
.selections
.iter()
.enumerate()
.filter(|(i, _)| overlap_indices.contains(i))
.any(|(_, selection)| self.selection_head(selection) > self.selection_tail(selection));
// Keep any non-overlapping selections.
let mut new_selections: Vec<Selection> = self
.selections
.iter()
.enumerate()
.flat_map(|(i, selection)| {
if !overlap_indices.contains(&i) {
Some(selection.clone())
} else {
None
}
})
.collect();
// Create any new selections from the overlapping selections.
for range in new_ranges {
let new_selection = if forwards_selection {
self.create_selection(range.end, range.start)
} else {
self.create_selection(range.start, range.end)
};
new_selections.push(new_selection);
}
match SelectionSet::try_from(new_selections) {
Ok(selections) => self.selections = selections,
Err(_) => {
log::error!("After removing overlapping selections, there were no selections left!")
}
}
}
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn first_selection_head(&self) -> CharOffset {
self.resolve_anchor(self.selections.first().head())
.expect("anchor should exist")
}
/// The current location of the selection head.
pub fn selection_head(&self, selection: &Selection) -> CharOffset {
self.resolve_anchor(selection.head())
.expect("anchor should exist")
}
pub fn selection_heads(&self) -> Vec1<CharOffset> {
self.selections
.selection_map(|s| self.resolve_anchor(s.head()).expect("anchor should exist"))
}
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn first_selection_tail(&self) -> CharOffset {
self.resolve_anchor(self.selection().tail())
.expect("anchor should exist")
}
/// The current location of the selection tail.
pub fn selection_tail(&self, selection: &Selection) -> CharOffset {
self.resolve_anchor(selection.tail())
.expect("anchor should exist")
}
/// Return all selected ranges.
pub fn selection_offsets(&self) -> Vec1<SelectionOffsets> {
self.selections.selection_map(|s| SelectionOffsets {
head: self.resolve_anchor(s.head()).expect("anchor should exist"),
tail: self.resolve_anchor(s.tail()).expect("anchor should exist"),
})
}
pub fn is_single_selection(&self) -> bool {
self.selections.len() == 1
}
/// Query for the set of text styles that are fully active over the current
/// selection.
pub fn selection_text_styles(&self, ctx: &AppContext) -> TextStylesWithMetadata {
self.selections_to_offset_ranges()
.into_iter()
.map(|range| self.buffer.as_ref(ctx).range_text_styles(range))
.reduce(|style1, style2| style1.mutual_styles(style2))
.expect("At least one selection style should exist")
}
pub fn active_block_type_at_selection(
&self,
selection: &Selection,
ctx: &AppContext,
) -> BlockType {
let range = self.selection_to_offset_range(selection);
self.buffer.as_ref(ctx).block_type_at_point(range.start)
}
/// Check whether the block type for every selection allow formatting.
pub fn all_selections_allow_formatting(&self, ctx: &AppContext) -> bool {
self.selections.iter().all(|selection| {
match self.active_block_type_at_selection(selection, ctx) {
BlockType::Text(block_style) => block_style.allows_formatting(),
// If pasting content directly after a rich block item, we should keep the content's original
// styling.
BlockType::Item(_) => true,
}
})
}
// Todo: kc (CLD1018) Temporary until multiselect is implemented.
pub fn first_selection_is_single_cursor(&self) -> bool {
// Selection should always be valid when we have single selections.
// TODO(kevin): migrate this when moving to multi-selection.
self.first_selection_head() == self.first_selection_tail()
}
pub fn selection_is_single_cursor(&self, selection: &Selection) -> bool {
self.selection_head(selection) == self.selection_tail(selection)
}
/// Return true if all selections are single cursors.
pub fn all_single_cursors(&self) -> bool {
self.selections
.iter()
.all(|s| self.selection_is_single_cursor(s))
}
pub fn cursors_at_line_start(&self, ctx: &AppContext) -> bool {
self.all_single_cursors();
for selection in self.selections.iter() {
let cursor_offset = self.selection_head(selection);
if cursor_offset != self.buffer.as_ref(ctx).containing_line_start(cursor_offset) {
return false;
}
}
true
}
/// Validate the buffer content with this selection model's anchors.
pub fn validate_buffer(&self, ctx: &impl warpui::ModelAsRef) {
self.buffer.as_ref(ctx).validate(&self.anchors);
}
pub(super) fn shift_selections_after_offset(&mut self, offset: CharOffset, delta: usize) {
for selection in self.selections.iter_mut() {
let head_offset = self
.anchors
.resolve(selection.head())
.expect("Anchor should be valid");
if head_offset >= offset && head_offset < offset + delta {
selection.set_head(&mut self.anchors, offset + delta);
}
let tail_offset = self
.anchors
.resolve(selection.tail())
.expect("Anchor should be valid");
if tail_offset >= offset && tail_offset < offset + delta {
selection.set_tail(&mut self.anchors, offset + delta);
}
}
}
pub(super) fn set_single_cursor(&mut self, offset: CharOffset) {
self.set_selection_offsets(vec1![SelectionOffsets {
head: offset,
tail: offset
}]);
}
pub(super) fn set_clamped_selection_head(
&mut self,
selection: &mut Selection,
offset: CharOffset,
) {
selection.set_head(&mut self.anchors, offset);
}
pub(super) fn set_clamped_selection_tail(
&mut self,
selection: &mut Selection,
offset: CharOffset,
) {
selection.set_tail(&mut self.anchors, offset);
}
pub(super) fn update_selection_offsets(&mut self, clamped_selections: Vec1<SelectionOffsets>) {
debug_assert!(
clamped_selections.len() == self.selections().len(),
"To update selection offsets, you must provide the same number of offset pairs as there are currently active selections"
);
for (selection, offsets) in self.selections.iter_mut().zip(clamped_selections.iter()) {
selection.set_head(&mut self.anchors, offsets.head);
selection.set_tail(&mut self.anchors, offsets.tail);
}
}
/// Clear the current selections and replace with this new set of selections.
/// All biases will be cleared.
pub fn set_selection_offsets(&mut self, selections: Vec1<SelectionOffsets>) {
let new_selections = selections.mapped(|offsets| {
let head_anchor = self.create_anchor(offsets.head, AnchorSide::Right);
let tail_anchor = self.create_anchor(offsets.tail, AnchorSide::Right);
Selection::new(head_anchor, tail_anchor)
});
self.set_selections(new_selections.into());
}
}
impl Entity for BufferSelectionModel {
type Event = ();
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
use markdown_parser::CodeBlockText;
use warpui::fonts::Weight;
use markdown_parser::FormattedTable;
use warp_core::features::FeatureFlag;
use super::{
BufferBlockItem, BufferTextStyle, CodeBlockType, MarkdownStyle, TextStyles,
format_image_markdown,
};
#[test]
fn test_text_style_xor() {
// This test makes sure that the `TextStyles` XOR implementations are updated as we add new styles.
for style in enum_iterator::all::<BufferTextStyle>() {
let mut with_style = TextStyles::default();
match style {
BufferTextStyle::Weight(weight) => {
with_style.set_weight(Weight::from_custom_weight(Some(weight)));
}
style => {
if let Some(style_mut) = with_style.style_mut(&style) {
*style_mut = true;
} else {
panic!("Impossible code path -- style {style:?} not handled");
}
}
}
assert!(
(with_style ^ TextStyles::default()).colliding_style(&style),
"Set ^ Unset = Set failed for {style:?}"
);
assert!(
(TextStyles::default() ^ with_style).colliding_style(&style),
"Unset ^ Set = Set failed for {style:?}"
);
assert!(
!(with_style ^ with_style).colliding_style(&style),
"Set ^ Set = Unset failed for {style:?}"
);
assert!(
!(TextStyles::default() ^ TextStyles::default()).colliding_style(&style),
"Unset ^ Unset = Unset failed for {style:?}"
);
let mut editable = with_style;
editable ^= with_style;
assert!(
!editable.colliding_style(&style),
"Set ^= Set -> Unset failed for {style:?}"
);
editable ^= TextStyles::default();
assert!(
!editable.colliding_style(&style),
"Unset ^= Unset -> Unset failed for {style:?}"
);
editable ^= with_style;
assert!(
editable.colliding_style(&style),
"Unset ^= Set -> Set failed for {style:?}"
);
editable ^= TextStyles::default();
assert!(
editable.colliding_style(&style),
"Set ^= Unset -> Set failed for {style:?}"
);
}
}
#[test]
fn test_formatted_table_round_trip() {
let input = "Name\tAge\nAlice\t30\nBob\t25\n";
let table = FormattedTable::from_internal_format(input);
assert_eq!(table.headers.len(), 2);
assert_eq!(table.rows.len(), 2);
assert_eq!(table.to_internal_format(), input);
}
#[test]
fn test_formatted_table_single_column() {
let input = "Header\nValue";
let table = FormattedTable::from_internal_format(input);
assert_eq!(table.headers.len(), 1);
assert_eq!(table.rows.len(), 1);
assert_eq!(table.to_internal_format(), "Header\nValue\n");
}
#[test]
fn test_formatted_table_empty_input() {
let table = FormattedTable::from_internal_format("");
assert!(table.headers.is_empty());
assert!(table.rows.is_empty());
}
#[test]
fn test_mermaid_code_block_type_respects_feature_flag() {
let markdown = CodeBlockText {
lang: "mermaid".to_string(),
code: "graph TD\nA --> B\n".to_string(),
};
let _disabled = FeatureFlag::MarkdownMermaid.override_enabled(false);
assert_eq!(
CodeBlockType::from(&markdown),
CodeBlockType::Code {
lang: "mermaid".to_string(),
}
);
drop(_disabled);
let _enabled = FeatureFlag::MarkdownMermaid.override_enabled(true);
assert_eq!(CodeBlockType::from(&markdown), CodeBlockType::Mermaid);
}
#[test]
fn test_formatted_table_normalize_shape() {
let input = "A\tB\tC\nX";
let mut table = FormattedTable::from_internal_format(input);
assert_eq!(table.rows[0].len(), 1);
table.normalize_shape();
assert_eq!(table.headers.len(), 3);
assert_eq!(table.rows[0].len(), 3);
}
#[test]
fn format_image_markdown_preserves_title() {
// No title -> canonical pre-title form.
assert_eq!(
format_image_markdown("alt", "src.png", None),
"![alt](src.png)"
);
// Empty title is equivalent to no title (product invariant 4).
assert_eq!(
format_image_markdown("alt", "src.png", Some("")),
"![alt](src.png)"
);
// Non-empty title is re-serialized with double quotes.
assert_eq!(
format_image_markdown("alt", "src.png", Some("caption")),
"![alt](src.png \"caption\")"
);
// Literal double quotes in the title are escaped with a backslash so the
// round-trip remains lossless.
assert_eq!(
format_image_markdown("alt", "src.png", Some("a \"quoted\" caption")),
"![alt](src.png \"a \\\"quoted\\\" caption\")"
);
}
#[test]
fn buffer_block_image_as_markdown_preserves_title() {
let untitled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: None,
};
assert_eq!(
&*untitled.as_markdown(MarkdownStyle::Internal),
"![A dog](dog.png)"
);
let titled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: Some("Rex, my dog".to_string()),
};
assert_eq!(
&*titled.as_markdown(MarkdownStyle::Internal),
"![A dog](dog.png \"Rex, my dog\")"
);
}
#[test]
fn buffer_block_image_partial_eq_considers_title() {
let untitled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: None,
};
let titled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: Some("Rex".to_string()),
};
assert_ne!(untitled, titled);
let titled_again = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: Some("Rex".to_string()),
};
assert_eq!(titled, titled_again);
}
+342
View File
@@ -0,0 +1,342 @@
use std::{collections::VecDeque, time::Duration};
use instant::Instant;
use warp_util::content_version::ContentVersion;
use crate::render::model::RenderedSelectionSet;
use super::core::{CoreEditorAction, ReplacementRange};
/// Threshold to separate two non-atomic undo items.
const UNDO_REDO_TIMER: Duration = Duration::from_millis(500);
/// Represents one high-level editor action that should be
/// undo/redo in one step. The high-level action could be broken
/// down into a set of smaller core editor actions.
///
/// Note that we also record the selection and replacement range state
/// before and after the editor action since they don't need to be
/// recalculated.
#[derive(Debug, Clone)]
pub struct ReversibleEditorActions {
pub actions: Vec<ReversibleEditorAction>,
pub selections: ReversibleSelectionState,
pub replacement_range: ReplacementRange,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UndoActionType {
// The action is atomic and should not be combined with any other action
// for a batched undo/redo.
Atomic,
// The action is non-atomic and could be undo/redo in a batch.
NonAtomic(NonAtomicType),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum NonAtomicType {
Insert,
Backspace,
}
impl NonAtomicType {
// Whether the change adds or removes content.
fn is_addition(&self) -> bool {
match self {
NonAtomicType::Insert => true,
NonAtomicType::Backspace => false,
}
}
}
/// A collection of editor actions that should be undo/redo together.
struct UndoStackItem {
items: VecDeque<Vec<ReversibleEditorAction>>,
selections: ReversibleSelectionState,
replacement_range: ReplacementRange,
// The version of the content after the original actions were applied.
version: ContentVersion,
}
impl UndoStackItem {
/// Create a new undo stack item.
/// The version of the buffer after the actions were applied is also recorded.
fn new(item: ReversibleEditorActions, version: ContentVersion) -> Self {
let mut new_items = VecDeque::new();
new_items.push_back(item.actions);
Self {
items: new_items,
selections: item.selections,
replacement_range: item.replacement_range,
version,
}
}
fn reverse(&mut self) {
let mut new_items = VecDeque::new();
// We are reversing three things here:
// 1. The batched edit actions' order
// 2. Within each edit action, its core edit actions' order
// 3. Each core edit action
for mut item in self.items.drain(..) {
item.reverse();
for action in &mut item {
std::mem::swap(&mut action.next, &mut action.reverse);
}
new_items.push_front(item);
}
self.items = new_items;
std::mem::swap(&mut self.selections.next, &mut self.selections.reverse);
std::mem::swap(
&mut self.replacement_range.new_range,
&mut self.replacement_range.old_range,
);
}
// Pushing another edit action to the batch.
fn add(
&mut self,
item: ReversibleEditorActions,
action: NonAtomicType,
version: ContentVersion,
) {
let is_addition = action.is_addition();
self.items.push_front(item.actions);
self.selections.reverse = item.selections.reverse;
self.version = version;
// This might be a bit confusing. For batched additions, the new range after the undo
// will always be the first edit actions' new range. The old range needs to be
// extended because we are inserting content into the buffer in each action.
//
// For batched deletions, the new ranges' start needs to be moved back with each edit because
// we are removing content from the buffer. The old range will be the range of the last edit
// actions' old range.
if is_addition {
self.replacement_range.old_range.end = self
.replacement_range
.old_range
.end
.max(item.replacement_range.old_range.end);
} else {
self.replacement_range.old_range = item.replacement_range.old_range;
self.replacement_range.new_range.start = self
.replacement_range
.new_range
.start
.min(item.replacement_range.new_range.start);
}
}
pub fn actions(&self) -> Vec<Vec<CoreEditorAction>> {
let mut result = Vec::new();
for item in &self.items {
result.push(item.iter().map(|action| action.next.clone()).collect());
}
result
}
pub fn selection(&self) -> RenderedSelectionSet {
self.selections.next.clone()
}
}
#[derive(Debug, Clone)]
pub struct ReversibleEditorAction {
pub next: CoreEditorAction,
pub reverse: CoreEditorAction,
}
#[derive(Debug, Clone)]
pub struct ReversibleSelectionState {
pub next: RenderedSelectionSet,
pub reverse: RenderedSelectionSet,
}
/// Changes need to be applied to the buffer model with a triggered
/// undo/redo action.
#[derive(Debug)]
pub struct UndoResult {
pub actions: Vec<Vec<CoreEditorAction>>,
pub selection: RenderedSelectionSet,
pub replacement_range: ReplacementRange,
}
#[derive(Debug, Clone)]
pub(super) struct UndoArg {
pub actions: Vec<ReversibleEditorAction>,
pub replacement_range: ReplacementRange,
}
pub(super) struct UndoStack {
stack: VecDeque<UndoStackItem>,
current_index: usize,
capacity: usize,
previous_action_type: Option<NonAtomicType>,
last_edit: Option<Instant>,
// The buffer version after everything is undone.
initial_version: ContentVersion,
}
impl UndoStack {
pub fn new(capacity: usize, initial_version: ContentVersion) -> UndoStack {
Self {
stack: Default::default(),
current_index: 0,
capacity,
previous_action_type: None,
last_edit: None,
initial_version,
}
}
pub fn clear_previous_non_atomic_type(&mut self) {
self.previous_action_type = None;
self.last_edit = None;
}
fn push_undo_item_to_stack(&mut self, item: ReversibleEditorActions, version: ContentVersion) {
let mut stack_size = self.stack.len();
if self.current_index < stack_size {
// We need to clear the redo stack if the user has undone already and then make edits.
self.stack.truncate(self.current_index);
stack_size = self.current_index;
}
if stack_size == self.capacity {
self.initial_version = self
.stack
.pop_front()
.expect("Undo stack is empty after checking size")
.version;
self.current_index -= 1;
}
self.stack.push_back(UndoStackItem::new(item, version));
self.current_index += 1;
}
#[cfg(test)]
pub(super) fn push_non_atomic_edit(
&mut self,
item: ReversibleEditorActions,
action: NonAtomicType,
version: ContentVersion,
) {
if self.previous_action_type == Some(action) && self.current_index > 0 {
self.stack[self.current_index - 1].add(item, action, version);
return;
}
self.previous_action_type = Some(action);
self.push_undo_item_to_stack(item, version);
}
pub fn push_new_edit(
&mut self,
item: ReversibleEditorActions,
action: UndoActionType,
version: ContentVersion,
) {
// An item should be pushed into the last batch if:
// 1. Action is non-atomic
// 2. Action matches the previous batch's non-atomic type
// 3. The gap between two edits hasn't exceeded threshold
if let UndoActionType::NonAtomic(action) = action {
let elapsed_time_within_limit = match self.last_edit {
Some(timer) => timer.elapsed() < UNDO_REDO_TIMER,
None => false,
};
if self.previous_action_type == Some(action)
&& self.current_index > 0
&& elapsed_time_within_limit
{
self.stack[self.current_index - 1].add(item, action, version);
return;
}
self.previous_action_type = Some(action);
self.last_edit = Some(Instant::now());
} else {
self.previous_action_type = None;
self.last_edit = None;
}
self.push_undo_item_to_stack(item, version);
}
pub fn undo(&mut self) -> Option<UndoResult> {
if self.stack.is_empty() || self.current_index == 0 {
return None;
}
self.previous_action_type = None;
self.current_index -= 1;
let undo_item = &self.stack[self.current_index];
let undo_result = UndoResult {
actions: undo_item.actions(),
selection: undo_item.selection(),
replacement_range: undo_item.replacement_range.clone(),
};
self.stack[self.current_index].reverse();
Some(undo_result)
}
pub fn redo(&mut self) -> Option<UndoResult> {
if self.current_index >= self.stack.len() {
return None;
}
let redo_item = &self.stack[self.current_index];
let redo_result = UndoResult {
actions: redo_item.actions(),
selection: redo_item.selection(),
replacement_range: redo_item.replacement_range.clone(),
};
self.stack[self.current_index].reverse();
self.current_index += 1;
Some(redo_result)
}
pub fn reset(&mut self, version: ContentVersion) {
self.stack.clear();
self.current_index = 0;
self.clear_previous_non_atomic_type();
self.initial_version = version;
}
/// Check if the given version matches the current version as reflected in the undo stack.
/// If at the bottom of the stack, check if it's equal to the earliest version of the content
/// that the undo stack was aware of.
/// If the stack is not empty, check if the version matches with the last completed item in the stack.
/// Due to undo/redo moving the `current_index` pointer in the stack, the last completed item is not
/// always the last item on the stack.
pub fn version_match(&self, version: &ContentVersion) -> bool {
if self.current_index == 0 {
return *version == self.initial_version;
}
self.stack[self.current_index - 1].version == *version
}
pub(super) fn set_initial_version(&mut self, version: ContentVersion) {
self.initial_version = version;
}
pub(super) fn is_empty(&self) -> bool {
self.stack.is_empty()
}
}
#[cfg(test)]
#[path = "undo_test.rs"]
pub mod tests;
+138
View File
@@ -0,0 +1,138 @@
use crate::render::model::RenderedSelection;
use super::*;
#[test]
fn test_version_match_initial() {
let version = ContentVersion::new();
let stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
}
fn fake_reversible_actions() -> ReversibleEditorActions {
ReversibleEditorActions {
actions: vec![],
selections: ReversibleSelectionState {
next: RenderedSelectionSet::new(RenderedSelection::new(0.into(), 0.into())),
reverse: RenderedSelectionSet::new(RenderedSelection::new(0.into(), 0.into())),
},
replacement_range: ReplacementRange {
new_range: 0.into()..0.into(),
old_range: 0.into()..0.into(),
},
}
}
#[test]
fn test_version_match_after_edit() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
let new_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), new_version);
assert!(stack.version_match(&new_version));
assert!(!stack.version_match(&version));
}
#[test]
fn test_version_match_after_undo() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
let new_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), new_version);
stack.undo();
assert!(!stack.version_match(&new_version));
assert!(stack.version_match(&version));
}
#[test]
fn test_version_match_after_redo() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
let new_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), new_version);
stack.undo();
stack.redo();
assert!(stack.version_match(&new_version));
assert!(!stack.version_match(&version));
}
#[test]
fn test_version_match_after_stack_overflow() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(3, version);
assert!(stack.version_match(&version));
let next_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), next_version);
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
stack.undo();
stack.undo();
stack.undo();
stack.undo();
assert!(stack.version_match(&next_version));
}
#[test]
fn test_version_match_after_nonatomic_undo_redo() {
// Make sure that after a series of nonatomic operations, the undo stack takes the newest version.
let version1 = ContentVersion::new();
let mut stack = UndoStack::new(5, version1);
assert!(stack.version_match(&version1));
let version2 = ContentVersion::new();
stack.push_non_atomic_edit(fake_reversible_actions(), NonAtomicType::Insert, version2);
let version3 = ContentVersion::new();
stack.push_non_atomic_edit(fake_reversible_actions(), NonAtomicType::Insert, version3);
assert!(stack.version_match(&version3));
stack.undo();
assert!(stack.version_match(&version1));
stack.redo();
assert!(stack.version_match(&version3));
}
#[test]
fn test_full_rollback() {
// Test that filling the capacity of the undo stack, undoing everything, and then making a new edit works.
// This was causing a panic.
let version1 = ContentVersion::new();
let mut stack = UndoStack::new(5, version1);
for _ in 0..5 {
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
}
assert_eq!(stack.capacity, stack.current_index);
for _ in 0..5 {
stack.undo();
}
assert_eq!(0, stack.current_index);
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
}
+143
View File
@@ -0,0 +1,143 @@
use pathfinder_color::ColorU;
use sum_tree::SumTree;
use crate::content::{
cursor::BufferSumTree,
text::{BlockLineBreakBehavior, BlockType, ColorMarker},
};
use super::text::{BufferBlockStyle, BufferSummary, BufferText, MarkerDir};
#[cfg(test)]
#[path = "validation_tests.rs"]
mod tests;
/// Validates a [`SumTree`] of content, panicking if it is not valid.
pub fn validate_content(content: &SumTree<BufferText>) {
let mut cursor = content.cursor::<(), BufferSummary>();
cursor.descend_to_first_item(content, |_| true);
let mut active_block_style: Option<BlockType> = None;
let mut active_color: Option<ColorU> = None;
while let Some(item) = cursor.item() {
let start_summary = cursor.start();
let char_offset = start_summary.text.chars;
match item {
BufferText::Text { .. } => {
assert!(
!matches!(active_block_style, Some(BlockType::Item(_))),
"{char_offset}: Found character, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
}
BufferText::Marker { marker_type, dir } => {
assert!(
!matches!(active_block_style, Some(BlockType::Item(_))),
"{char_offset}: Found style marker, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
let style_depth = start_summary.style_summary().style_counter(marker_type);
match dir {
MarkerDir::Start => {
assert!(
style_depth == 0,
"{char_offset}: Found {marker_type:?} start marker, but style was already active\nBuffer: {}",
content.debug()
);
}
MarkerDir::End => {
assert!(
style_depth == 1,
"{char_offset}: Found {marker_type:?} end marker, but style was not active\nBuffer: {}",
content.debug()
);
}
}
}
BufferText::Newline => match active_block_style.clone() {
Some(BlockType::Text(block_style)) => {
assert!(
block_style.line_break_behavior() == BlockLineBreakBehavior::NewLine,
"{char_offset}: Found newline, but active block style only supports single line\nBuffer: {}",
content.debug()
);
}
Some(BlockType::Item(_)) => {
panic!(
"{char_offset}: Found newline, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
}
None => (),
},
BufferText::BlockItem { item_type } => {
active_block_style = Some(BlockType::Item(item_type.clone()));
}
BufferText::BlockMarker { marker_type } => {
if Some(BlockType::Text(BufferBlockStyle::PlainText)) == active_block_style {
assert!(
marker_type != &BufferBlockStyle::PlainText,
"{char_offset}: Found plain text marker when the active style is plain text\nBuffer: {}",
content.debug()
);
}
active_block_style = Some(BlockType::Text(marker_type.clone()));
}
BufferText::Color(color_marker) => {
assert!(
matches!(
active_block_style,
Some(BlockType::Text(BufferBlockStyle::CodeBlock { .. }))
),
"{char_offset}: Found syntax color marker, but active block item is not a code block\nBuffer: {}",
content.debug()
);
match color_marker {
ColorMarker::Start(color) => {
assert!(
active_color.is_none(),
"{char_offset}: Found a starting syntax color marker when there is already an active syntax color\nBuffer: {}",
content.debug()
);
active_color = Some(*color);
}
ColorMarker::End => {
assert!(
active_color.is_some(),
"{char_offset}: Found an ending syntax color marker when there is no active syntax color\nBuffer: {}",
content.debug()
);
active_color = None;
}
}
}
BufferText::Placeholder { .. } | BufferText::Link(_) => {
assert!(
!matches!(active_block_style, Some(BlockType::Item(_))),
"{char_offset}: Found link/placeholder, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
}
}
assert!(
active_block_style.is_some(),
"{char_offset}: Buffer doesn't have an active block style.\nBuffer: {}",
content.debug()
);
cursor.next();
}
// Buffers must end as plain text.
assert!(
active_block_style
.clone()
.is_some_and(|style| style == BlockType::Text(BufferBlockStyle::PlainText)),
"Buffer ends as {active_block_style:?}, not plain text.\nBuffer: {}",
content.debug()
);
}
@@ -0,0 +1,239 @@
use crate::content::{
cursor::BufferSumTree,
text::{
BlockHeaderSize, BufferBlockItem, BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir,
},
};
use sum_tree::SumTree;
use warpui::elements::ListIndentLevel;
#[test]
#[should_panic(
expected = "2: Found Weight(Bold) end marker, but style was not active\nBuffer: <text>x<b_e>"
)]
fn test_validate_unmatched_style_end() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("x");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "3: Found Italic start marker, but style was already active\nBuffer: <text>x<i_s>y<i_s><i_e>"
)]
fn test_validate_unmatched_style_start() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("x");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.append_str("y");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::End,
});
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "2: Found newline, but active block style only supports single line\nBuffer: <header1>x\\n"
)]
fn test_validate_single_line_header() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::Header {
header_size: BlockHeaderSize::Header1,
},
});
tree.append_str("x");
tree.push(BufferText::Newline);
super::validate_content(&tree);
}
#[test]
/*
* TODO: this test should panic once we prevent styling in code blocks.
#[should_panic(
expected = "3: Found Bold Start marker inside runnable command block\nBuffer: <text>a<code>x<b_s>y<text><b_e>"
)]
*/
fn test_validate_styled_code() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("a");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
});
tree.append_str("x");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::Start,
});
tree.append_str("y");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
// The marker pairs are balanced, but there cannot be a bold marker inside
// a runnable command.
super::validate_content(&tree);
}
// #[test]
// #[should_panic(
// expected = "2: Tried to start a RunnableCodeBlock block, but Bold was active\nBuffer: a<b_s>b<code_s>c<code_e>d<b_e>"
// )]
// fn test_validate_start_block_with_style() {
// let mut tree = SumTree::new();
// tree.push('a'.into());
// tree.push(BufferText::Marker {
// marker_type: BufferTextStyle::Bold,
// dir: MarkerDir::Start,
// });
// tree.push('b'.into());
// tree.push(BufferText::BlockMarker {
// marker_type: BufferBlockStyle::CodeBlock,
// dir: MarkerDir::Start,
// });
// tree.push('c'.into());
// tree.push(BufferText::BlockMarker {
// marker_type: BufferBlockStyle::CodeBlock,
// dir: MarkerDir::End,
// });
// tree.push('d'.into());
// tree.push(BufferText::Marker {
// marker_type: BufferTextStyle::Bold,
// dir: MarkerDir::End,
// });
// // The marker pairs are balanced, but there cannot be a style active when
// // starting a runnable command block.
// super::validate_content(&tree);
// }
#[test]
#[should_panic(expected = "0: Buffer doesn't have an active block style.\nBuffer: \\ny")]
fn test_validate_buffer_without_start_marker() {
let mut tree = SumTree::new();
tree.append_str("\ny");
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "2: Found plain text marker when the active style is plain text\nBuffer: <text>y<text>x"
)]
fn test_validate_buffer_with_dup_text_marker() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("y");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("x");
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "Buffer ends as Some(Text(UnorderedList { indent_level: One })), not plain text.\nBuffer: <text>t<ul0>l"
)]
fn test_validate_buffer_ends_with_plain_text() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("t");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::UnorderedList {
indent_level: ListIndentLevel::One,
},
});
tree.append_str("l");
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "1: Found character, but active block item does not decorate text\nBuffer: <hr>t"
)]
fn test_validate_block_item_not_decorating_text() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockItem {
item_type: BufferBlockItem::HorizontalRule,
});
tree.append_str("t");
super::validate_content(&tree);
}
#[test]
fn test_validate_ok() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("a");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.append_str("i");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::End,
});
tree.push(BufferText::BlockItem {
item_type: BufferBlockItem::HorizontalRule,
});
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
});
tree.append_str("x");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
});
tree.append_str("z");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
// This should not panic.
super::validate_content(&tree);
}
+18
View File
@@ -0,0 +1,18 @@
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone, PartialEq, Debug, Copy, Eq, PartialOrd, Ord, Hash)]
pub struct BufferVersion(usize);
impl BufferVersion {
/// Constructs a new app-unique content version.
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
BufferVersion(raw)
}
pub fn as_usize(&self) -> usize {
self.0
}
}