Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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
@@ -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], ¶graph_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], ¶graph_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], ¶graph_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)),
|
||||
]
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 = "\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 = "\n\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\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))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
""
|
||||
);
|
||||
|
||||
// Empty title is equivalent to no title (product invariant 4).
|
||||
assert_eq!(
|
||||
format_image_markdown("alt", "src.png", Some("")),
|
||||
""
|
||||
);
|
||||
|
||||
// Non-empty title is re-serialized with double quotes.
|
||||
assert_eq!(
|
||||
format_image_markdown("alt", "src.png", Some("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")),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[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),
|
||||
""
|
||||
);
|
||||
|
||||
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),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user